asterism branch orbit-impl-2/immutable-capture-safety-net commits 7 unpushed files 94 touched lines +12649 / -198

Pre-push review: immutable-capture-safety-net

Same-model pre-push review of the M1 immutable-capture & safety-net implementation and the Safari share-URL bugfix (commit 7355edb), diffed against main.

At a glance

  • Bugfix 7355edb accepts Safari location.href as the raw URL when the preprocessing property list is the only attachment — matches requirements 2.3/2.4, smallest viable change, regression test added.
  • Persistence is routed through a single actor-based LibraryRepository with a cross-process flock lease; compound writes are atomic (one context, one save, discard on failure).
  • Fail-closed bootstrap: a durable init marker distinguishes first use from a missing established store, so a vanished library is never silently replaced.
  • Backup export is validated fail-closed (encode → strict decode → typed deep-equality vs source → checksum over exact payload bytes) before any file is offered.
  • Verification: 186/186 AsterismCore tests pass; Safari regression passes; make build succeeds for app + embedded extension.
  • No code changes were applied in this review — no genuine must-fix issues were identified.

Verdict

Ready to push

The branch is a coherent, well-structured M1 foundation. The Safari share-URL bugfix (7355edb) is minimal, correct, and covered by a focused regression test. The core logic is exercised by 186 passing AsterismCore package tests and the app + extension target builds cleanly. No must-fix correctness, security, or spec-divergence issues were found; the remaining observations are minor nits that are safe to defer.

Review findings

4 raised · 0 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Asterism gained a real local library: a reader can share a web page into the app, keep the original URL and best available title, add a note or rating, and save — then browse captures in Recent, organise them into Works, and export a verified JSON backup. A bug that made Safari shares fail with “a page URL is required” was also fixed.

Why It Matters

This is the data foundation before real notes accumulate. Capture evidence is immutable, saves are all-or-nothing, the app and share extension safely share one store, and every backup is checked against the full library before it is offered.

Key Concepts

  • App Group: a shared folder letting the app and extension use one library.
  • Snapshot: an immutable value copied out of the database for the UI.
  • Fail closed: if the library can't be proven safe to open, stop rather than create an empty one.
  • Canonical backup: JSON with fixed ordering so its exact bytes can be checksummed.

Architecture

Shared logic lives in the AsterismCore Swift package (V1 SwiftData schema, identity normalization, repository + cross-process lock, capture coordination, bounded metadata fetch, canonical backup codec). The app and share extension are process-specific clients that exchange only Sendable snapshots with the core.

Patterns

LibraryRepository is the sole writer: every operation takes a shared (read) or exclusive (write/bootstrap) flock lease, creates a fresh ModelContext, materialises or mutates fully, then releases context and lease together. The Safari bugfix widened the provider-URL guard to providerURL ?? safari?.locationHref.

Trade-offs

The repository/snapshot boundary is more explicit than direct @Query use, but makes atomicity, cross-process refresh, rollback and backup coherence testable. A custom bounded HTML scanner avoids a full parser dependency inside the extension's memory/time budget.

Deep Dive

The repository actor serialises in-process access while flock leases serialise compound operations across app and extension. Bootstrap combines an explicit store URL with a durable init marker: neither file = first use; both = validate and open; marker without store = fail closed; store without marker = validate then recover the marker. No operation suspends while holding a lease.

Identity & Backup

Identity normalization lexes the URL (no Foundation reconstruction), rewriting only scheme, host, default port and the listed tracking-query names while preserving path, fragment, retained query bytes, duplicates and order. Backup V1 assembles canonical payload bytes once, embeds them unchanged, and validates via SHA-256 + strict decode + typed deep equality + inventory / count / uniqueness / reference checks — defeating a consistently faulty encoder.

Edge Cases

Recent and Works are each individually coherent but read via separate snapshots, so a cross-process write between tab reads can briefly show different moments (accepted by requirements). Backup filenames are second-precision; two exports within one second would overwrite the staged file (implausible, non-destructive). The head-scanner's extractMetaCharset indexes the original string with indices from its lowercased copy — safe for ASCII charset declarations.

Important changes — detailed

Safari share URL accepted without a redundant provider

Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift

Why it matters. The primary Safari capture flow failed with “a page URL is required” because a separate UTType.url attachment was demanded even when Safari supplied an authoritative location.href.

What to look at. SharePayloadExtractor.swift:116 (guard let url = providerURL ?? safari?.locationHref)

Takeaway. When a platform delivers authoritative data through one of several possible representations, gate on the union of sources, not on one specific transport channel.
Rationale. Smallest change matching requirements 2.3 (Safari location.href is authoritative raw URL) and 2.4 (provider URL is the fallback), avoiding a public-API change to make providerURL optional.

Actor + cross-process lock repository as the sole writer

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift

Why it matters. App and extension are separate processes writing one store; correctness of atomic compound writes and coherent multi-fetch snapshots depends on this boundary.

What to look at. LibraryRepository.withLockedContext / capture / moveEntry / deleteEntry

Takeaway. Pair an in-process actor (data-race safety) with an inter-process flock lease (cross-process serialisation); never suspend while holding the lease.
Rationale. Decision 12/16: one synchronization policy for compound mutations and snapshots; SwiftData models never cross actor or UI boundaries.

Fail-closed bootstrap with a durable init marker

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift

Why it matters. An explicit store URL alone cannot distinguish first install from an established store whose files vanished; opening the latter blindly would silently create an empty library.

What to look at. LibraryRepository.open (marker/store presence matrix)

Takeaway. A separate durable presence marker turns “missing data” into a visible fail-closed error instead of a silent empty replacement.
Rationale. Decision 15: marker written only after the store validates; marker-without-store fails, store-without-marker is validated then recovered.

Backup validated against the source snapshot before any file exists

Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift

Why it matters. Self-decoding cannot catch records that both the encoder and header omit consistently; the safety-net's whole value is proven completeness.

What to look at. BackupExporter.export (snapshot → encode → decode → validate → stage)

Takeaway. Validate an export against the live source graph (typed deep equality) plus a byte checksum, not just internal self-consistency.
Rationale. Decisions 9/14/17: canonical payload bytes hashed once and embedded unchanged; deep equality proves every field value survived encoding.

Lexical URL identity normalization (no Foundation reconstruction)

Packages/AsterismCore/Sources/AsterismCore/IdentityNormalization.swift

Why it matters. Identity keys must be recomputable and must never alter the immutable raw-URL evidence they are derived from.

What to look at. LexicalHTTPURL / removingTrackers / normalizeAuthority

Takeaway. For immutable evidence, lex the string and rewrite only the named parts; never round-trip through a URL type that may re-encode bytes.
Rationale. Requirement 1.7 / Decision 7: preserve path, fragment, retained query bytes, duplicates and order; strip only default ports and the enumerated tracking parameters.

make install device selection and product path corrected

Makefile

Why it matters. Network-paired devices sit at tunnelState “disconnected” at rest and the built product is Asterism.app (not the scheme name with a space), so install could select nothing or point at a missing path.

What to look at. Makefile DEVICE_MODEL / pairingState select / quoted APP_NAME path

Takeaway. Match devicectl on pairingState rather than tunnelState, and quote build-product paths that contain spaces.
Rationale. Commit 9a0691d body: default DEVICE_MODEL to iPhone 17 Pro and install the real product name via APP_NAME.

Key decisions

Widen the provider-URL guard rather than make providerURL optional.

Keeping SharePayload.providerURL non-optional avoids churn across every caller for a transport-only compatibility case; Safari location.href simply satisfies the fallback field. (Bugfix report, alternatives considered.)

Single shared local store split into Personal/Development in M1.

Decision 19 supersedes the earlier single-store plan (Decision 5): build-time App Group isolation prevents development builds from opening the daily-use library before restore exists.

Custom bounded HTML head scanner instead of a parser dependency.

Decision 13: the extension needs only <title> and canonical links within a 64 KiB / 3 s budget; a streaming scanner keeps extension binary and memory cost bounded.

CloudKit-compatible optional relationship storage.

Decision 22: Entry.work, Work.entries, Site.patterns, TitlePattern.site are optional with explicit inverses; the repository normalises nil-to-empty and enforces required links above persistence to avoid an M4 migration.

Review findings

SeverityAreaFindingResolution
infoSharePayloadExtractor.swiftSafari bugfix correctly gates on providerURL ?? safari?.locationHref and preserves provider fallback for non-Safari hosts; regression test asserts no redundant URL load occurs.Verified correct against requirements 2.3/2.4; regression test passes. No change needed.
nitBackupExporter.generateFilenameBackup filename uses second-precision ISO-8601; two exports within the same second would collide and the atomic write would overwrite the earlier staged file.Not fixed: exports are user-initiated via the share sheet, so sub-second repeats are implausible and the outcome is a non-destructive overwrite of an unshared staged file.
nitBackupExporter.scavengeStaleFilesScavenging uses wall-clock Date() rather than the injected RepositoryClock, so it is not driven by the test clock seam.Not fixed: 24-hour staged-file scavenging is inherently wall-clock scoped and does not affect library correctness.
nitHeadMetadataScanner.extractMetaCharsetIndices derived from text.lowercased() are used to slice the original text; non-ASCII bytes before a charset declaration could theoretically misalign.Not fixed: charset declarations are ASCII and this runs on the first 1024 bytes; misdetection merely falls through to the UTF-8/Windows-1252 decode ladder.

Per-file diffs

Click to expand.

Asterism/Asterism.xcodeproj/project.pbxproj Modified +171 / -29
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex 4a60516..40a4f63 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -6,6 +6,13 @@ 	objectVersion = 77; 	objects = { +/* Begin PBXBuildFile section */+		A10000000000000000000001 /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000011 /* AsterismCore */; };+		A10000000000000000000002 /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000012 /* AsterismCore */; };+		A10000000000000000000003 /* AsterismShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* AsterismShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };+		A10000000000000000000014 /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000013 /* AsterismCore */; };+/* End PBXBuildFile section */+ /* Begin PBXContainerItemProxy section */ 		D4A9C7A630091595004199A5 /* PBXContainerItemProxy */ = { 			isa = PBXContainerItemProxy;@@ -21,12 +28,20 @@ 			remoteGlobalIDString = D4A9C79330091593004199A5; 			remoteInfo = Asterism; 		};+		A1000000000000000000000E /* PBXContainerItemProxy */ = {+			isa = PBXContainerItemProxy;+			containerPortal = D4A9C78C30091593004199A5 /* Project object */;+			proxyType = 1;+			remoteGlobalIDString = A10000000000000000000009;+			remoteInfo = AsterismShareExtension;+		}; /* End PBXContainerItemProxy section */  /* Begin PBXFileReference section */ 		D4A9C79430091593004199A5 /* Asterism.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Asterism.app; sourceTree = BUILT_PRODUCTS_DIR; }; 		D4A9C7A530091595004199A5 /* AsterismTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AsterismTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 		D4A9C7AF30091595004199A5 /* AsterismUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AsterismUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };+		A10000000000000000000004 /* AsterismShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AsterismShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */  /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */@@ -37,6 +52,11 @@ 			); 			target = D4A9C79330091593004199A5 /* Asterism */; 		};+		A10000000000000000000015 /* Exceptions for "AsterismShareExtension" folder */ = {+			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;+			membershipExceptions = (Info.plist, );+			target = A10000000000000000000009 /* AsterismShareExtension */;+		}; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */  /* Begin PBXFileSystemSynchronizedRootGroup section */@@ -58,13 +78,32 @@ 			path = AsterismUITests; 			sourceTree = "<group>"; 		};+		A10000000000000000000005 /* AsterismShareExtension */ = {+			isa = PBXFileSystemSynchronizedRootGroup;+			exceptions = (A10000000000000000000015 /* Exceptions for "AsterismShareExtension" folder */, );+			path = AsterismShareExtension;+			sourceTree = "<group>";+		}; /* End PBXFileSystemSynchronizedRootGroup section */ +/* Begin PBXCopyFilesBuildPhase section */+		A1000000000000000000000D /* Embed App Extensions */ = {+			isa = PBXCopyFilesBuildPhase;+			buildActionMask = 2147483647;+			dstPath = "";+			dstSubfolderSpec = 13;+			files = (A10000000000000000000003 /* AsterismShareExtension.appex in Embed App Extensions */, );+			name = "Embed App Extensions";+			runOnlyForDeploymentPostprocessing = 0;+		};+/* End PBXCopyFilesBuildPhase section */+ /* Begin PBXFrameworksBuildPhase section */ 		D4A9C79130091593004199A5 /* Frameworks */ = { 			isa = PBXFrameworksBuildPhase; 			buildActionMask = 2147483647; 			files = (+				A10000000000000000000001 /* AsterismCore in Frameworks */, 			); 			runOnlyForDeploymentPostprocessing = 0; 		};@@ -72,6 +111,7 @@ 			isa = PBXFrameworksBuildPhase; 			buildActionMask = 2147483647; 			files = (+				A10000000000000000000014 /* AsterismCore in Frameworks */, 			); 			runOnlyForDeploymentPostprocessing = 0; 		};@@ -82,6 +122,12 @@ 			); 			runOnlyForDeploymentPostprocessing = 0; 		};+		A10000000000000000000006 /* Frameworks */ = {+			isa = PBXFrameworksBuildPhase;+			buildActionMask = 2147483647;+			files = (A10000000000000000000002 /* AsterismCore in Frameworks */, );+			runOnlyForDeploymentPostprocessing = 0;+		}; /* End PBXFrameworksBuildPhase section */  /* Begin PBXGroup section */@@ -91,6 +137,7 @@ 				D4A9C79630091593004199A5 /* Asterism */, 				D4A9C7A830091595004199A5 /* AsterismTests */, 				D4A9C7B230091595004199A5 /* AsterismUITests */,+				A10000000000000000000005 /* AsterismShareExtension */, 				D4A9C79530091593004199A5 /* Products */, 			); 			sourceTree = "<group>";@@ -101,6 +148,7 @@ 				D4A9C79430091593004199A5 /* Asterism.app */, 				D4A9C7A530091595004199A5 /* AsterismTests.xctest */, 				D4A9C7AF30091595004199A5 /* AsterismUITests.xctest */,+				A10000000000000000000004 /* AsterismShareExtension.appex */, 			); 			name = Products; 			sourceTree = "<group>";@@ -115,16 +163,19 @@ 				D4A9C79030091593004199A5 /* Sources */, 				D4A9C79130091593004199A5 /* Frameworks */, 				D4A9C79230091593004199A5 /* Resources */,+				A1000000000000000000000D /* Embed App Extensions */, 			); 			buildRules = ( 			); 			dependencies = (+				A1000000000000000000000F /* PBXTargetDependency */, 			); 			fileSystemSynchronizedGroups = ( 				D4A9C79630091593004199A5 /* Asterism */, 			); 			name = Asterism; 			packageProductDependencies = (+				A10000000000000000000011 /* AsterismCore */, 			); 			productName = Asterism; 			productReference = D4A9C79430091593004199A5 /* Asterism.app */;@@ -148,6 +199,7 @@ 			); 			name = AsterismTests; 			packageProductDependencies = (+				A10000000000000000000013 /* AsterismCore */, 			); 			productName = AsterismTests; 			productReference = D4A9C7A530091595004199A5 /* AsterismTests.xctest */;@@ -176,6 +228,19 @@ 			productReference = D4A9C7AF30091595004199A5 /* AsterismUITests.xctest */; 			productType = "com.apple.product-type.bundle.ui-testing"; 		};+		A10000000000000000000009 /* AsterismShareExtension */ = {+			isa = PBXNativeTarget;+			buildConfigurationList = A1000000000000000000000A /* Build configuration list for PBXNativeTarget "AsterismShareExtension" */;+			buildPhases = (A10000000000000000000007 /* Sources */, A10000000000000000000006 /* Frameworks */, A10000000000000000000008 /* Resources */, );+			buildRules = ();+			dependencies = ();+			fileSystemSynchronizedGroups = (A10000000000000000000005 /* AsterismShareExtension */, );+			name = AsterismShareExtension;+			packageProductDependencies = (A10000000000000000000012 /* AsterismCore */, );+			productName = AsterismShareExtension;+			productReference = A10000000000000000000004 /* AsterismShareExtension.appex */;+			productType = "com.apple.product-type.app-extension";+		}; /* End PBXNativeTarget section */  /* Begin PBXProject section */@@ -197,6 +262,7 @@ 						CreatedOnToolsVersion = 26.6; 						TestTargetID = D4A9C79330091593004199A5; 					};+					A10000000000000000000009 = { CreatedOnToolsVersion = 26.6; }; 				}; 			}; 			buildConfigurationList = D4A9C78F30091593004199A5 /* Build configuration list for PBXProject "Asterism" */;@@ -210,12 +276,14 @@ 			minimizedProjectReferenceProxies = 1; 			preferredProjectObjectVersion = 77; 			productRefGroup = D4A9C79530091593004199A5 /* Products */;+			packageReferences = (A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */, ); 			projectDirPath = ""; 			projectRoot = ""; 			targets = ( 				D4A9C79330091593004199A5 /* Asterism */, 				D4A9C7A430091595004199A5 /* AsterismTests */, 				D4A9C7AE30091595004199A5 /* AsterismUITests */,+				A10000000000000000000009 /* AsterismShareExtension */, 			); 		}; /* End PBXProject section */@@ -242,6 +310,7 @@ 			); 			runOnlyForDeploymentPostprocessing = 0; 		};+		A10000000000000000000008 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */  /* Begin PBXSourcesBuildPhase section */@@ -266,6 +335,7 @@ 			); 			runOnlyForDeploymentPostprocessing = 0; 		};+		A10000000000000000000007 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */  /* Begin PBXTargetDependency section */@@ -279,14 +349,18 @@ 			target = D4A9C79330091593004199A5 /* Asterism */; 			targetProxy = D4A9C7B030091595004199A5 /* PBXContainerItemProxy */; 		};+		A1000000000000000000000F /* PBXTargetDependency */ = {isa = PBXTargetDependency; target = A10000000000000000000009 /* AsterismShareExtension */; targetProxy = A1000000000000000000000E /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */  /* Begin XCBuildConfiguration section */-		D4A9C7B930091595004199A5 /* Debug */ = {+		D4A9C7B930091595004199A5 /* Development */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;+				ASTERISM_APP_GROUP_IDENTIFIER = group.me.nore.ig.Asterism.dev;+				ASTERISM_STORE_RELATIVE_PATH = "Library/Application Support/Asterism.sqlite";+				CODE_SIGN_ENTITLEMENTS = Asterism/Asterism.Development.entitlements; 				CODE_SIGN_STYLE = Automatic; 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN;@@ -311,7 +385,7 @@ 				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 				MACOSX_DEPLOYMENT_TARGET = 26.5; 				MARKETING_VERSION = 1.0;-				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.Asterism;+				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.Asterism.dev; 				PRODUCT_NAME = "$(TARGET_NAME)"; 				REGISTER_APP_GROUPS = YES; 				SDKROOT = auto;@@ -325,13 +399,16 @@ 				TARGETED_DEVICE_FAMILY = "1,2,7"; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Debug;+			name = Development; 		};-		D4A9C7BA30091595004199A5 /* Release */ = {+		D4A9C7BA30091595004199A5 /* Personal */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;+				ASTERISM_APP_GROUP_IDENTIFIER = group.me.nore.ig.Asterism;+				ASTERISM_STORE_RELATIVE_PATH = "Library/Application Support/Asterism.sqlite";+				CODE_SIGN_ENTITLEMENTS = Asterism/Asterism.Personal.entitlements; 				CODE_SIGN_STYLE = Automatic; 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN;@@ -370,9 +447,9 @@ 				TARGETED_DEVICE_FAMILY = "1,2,7"; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Release;+			name = Personal; 		};-		D4A9C7BB30091595004199A5 /* Debug */ = {+		D4A9C7BB30091595004199A5 /* Development */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				ALWAYS_SEARCH_USER_PATHS = NO;@@ -432,9 +509,9 @@ 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 			};-			name = Debug;+			name = Development; 		};-		D4A9C7BC30091595004199A5 /* Release */ = {+		D4A9C7BC30091595004199A5 /* Personal */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				ALWAYS_SEARCH_USER_PATHS = NO;@@ -486,9 +563,9 @@ 				MTL_FAST_MATH = YES; 				SWIFT_COMPILATION_MODE = wholemodule; 			};-			name = Release;+			name = Personal; 		};-		D4A9C7BE30091595004199A5 /* Debug */ = {+		D4A9C7BE30091595004199A5 /* Development */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				BUNDLE_LOADER = "$(TEST_HOST)";@@ -512,9 +589,9 @@ 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Asterism.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Asterism"; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Debug;+			name = Development; 		};-		D4A9C7BF30091595004199A5 /* Release */ = {+		D4A9C7BF30091595004199A5 /* Personal */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				BUNDLE_LOADER = "$(TEST_HOST)";@@ -538,9 +615,9 @@ 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Asterism.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Asterism"; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Release;+			name = Personal; 		};-		D4A9C7C130091595004199A5 /* Debug */ = {+		D4A9C7C130091595004199A5 /* Development */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				CODE_SIGN_STYLE = Automatic;@@ -563,9 +640,9 @@ 				TEST_TARGET_NAME = Asterism; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Debug;+			name = Development; 		};-		D4A9C7C230091595004199A5 /* Release */ = {+		D4A9C7C230091595004199A5 /* Personal */ = { 			isa = XCBuildConfiguration; 			buildSettings = { 				CODE_SIGN_STYLE = Automatic;@@ -588,7 +665,56 @@ 				TEST_TARGET_NAME = Asterism; 				XROS_DEPLOYMENT_TARGET = 26.5; 			};-			name = Release;+			name = Personal;+		};+		A1000000000000000000000B /* Development */ = {+			isa = XCBuildConfiguration;+			buildSettings = {+				APPLICATION_EXTENSION_API_ONLY = YES;+				ASTERISM_APP_GROUP_IDENTIFIER = group.me.nore.ig.Asterism.dev;+				CODE_SIGN_ENTITLEMENTS = AsterismShareExtension/AsterismShareExtension.Development.entitlements;+				CODE_SIGN_STYLE = Automatic;+				CURRENT_PROJECT_VERSION = 1;+				DEVELOPMENT_TEAM = V24684SCZN;+				GENERATE_INFOPLIST_FILE = NO;+				INFOPLIST_FILE = AsterismShareExtension/Info.plist;+				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";+				MARKETING_VERSION = 1.0;+				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.Asterism.dev.ShareExtension;+				PRODUCT_NAME = "$(TARGET_NAME)";+				SKIP_INSTALL = YES;+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;+				SWIFT_VERSION = 5.0;+				TARGETED_DEVICE_FAMILY = "1,2";+			};+			name = Development;+		};+		A1000000000000000000000C /* Personal */ = {+			isa = XCBuildConfiguration;+			buildSettings = {+				APPLICATION_EXTENSION_API_ONLY = YES;+				ASTERISM_APP_GROUP_IDENTIFIER = group.me.nore.ig.Asterism;+				CODE_SIGN_ENTITLEMENTS = AsterismShareExtension/AsterismShareExtension.Personal.entitlements;+				CODE_SIGN_STYLE = Automatic;+				CURRENT_PROJECT_VERSION = 1;+				DEVELOPMENT_TEAM = V24684SCZN;+				GENERATE_INFOPLIST_FILE = NO;+				INFOPLIST_FILE = AsterismShareExtension/Info.plist;+				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";+				MARKETING_VERSION = 1.0;+				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.Asterism.ShareExtension;+				PRODUCT_NAME = "$(TARGET_NAME)";+				SKIP_INSTALL = YES;+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;+				SWIFT_VERSION = 5.0;+				TARGETED_DEVICE_FAMILY = "1,2";+			};+			name = Personal; 		}; /* End XCBuildConfiguration section */ @@ -596,40 +722,56 @@ 		D4A9C78F30091593004199A5 /* Build configuration list for PBXProject "Asterism" */ = { 			isa = XCConfigurationList; 			buildConfigurations = (-				D4A9C7BB30091595004199A5 /* Debug */,-				D4A9C7BC30091595004199A5 /* Release */,+				D4A9C7BB30091595004199A5 /* Development */,+				D4A9C7BC30091595004199A5 /* Personal */, 			); 			defaultConfigurationIsVisible = 0;-			defaultConfigurationName = Release;+			defaultConfigurationName = Personal; 		}; 		D4A9C7B830091595004199A5 /* Build configuration list for PBXNativeTarget "Asterism" */ = { 			isa = XCConfigurationList; 			buildConfigurations = (-				D4A9C7B930091595004199A5 /* Debug */,-				D4A9C7BA30091595004199A5 /* Release */,+				D4A9C7B930091595004199A5 /* Development */,+				D4A9C7BA30091595004199A5 /* Personal */, 			); 			defaultConfigurationIsVisible = 0;-			defaultConfigurationName = Release;+			defaultConfigurationName = Personal; 		}; 		D4A9C7BD30091595004199A5 /* Build configuration list for PBXNativeTarget "AsterismTests" */ = { 			isa = XCConfigurationList; 			buildConfigurations = (-				D4A9C7BE30091595004199A5 /* Debug */,-				D4A9C7BF30091595004199A5 /* Release */,+				D4A9C7BE30091595004199A5 /* Development */,+				D4A9C7BF30091595004199A5 /* Personal */, 			); 			defaultConfigurationIsVisible = 0;-			defaultConfigurationName = Release;+			defaultConfigurationName = Personal; 		}; 		D4A9C7C030091595004199A5 /* Build configuration list for PBXNativeTarget "AsterismUITests" */ = { 			isa = XCConfigurationList; 			buildConfigurations = (-				D4A9C7C130091595004199A5 /* Debug */,-				D4A9C7C230091595004199A5 /* Release */,+				D4A9C7C130091595004199A5 /* Development */,+				D4A9C7C230091595004199A5 /* Personal */, 			); 			defaultConfigurationIsVisible = 0;-			defaultConfigurationName = Release;+			defaultConfigurationName = Personal;+		};+		A1000000000000000000000A /* Build configuration list for PBXNativeTarget "AsterismShareExtension" */ = {+			isa = XCConfigurationList;+			buildConfigurations = (A1000000000000000000000B /* Development */, A1000000000000000000000C /* Personal */, );+			defaultConfigurationIsVisible = 0;+			defaultConfigurationName = Personal; 		}; /* End XCConfigurationList section */++/* Begin XCLocalSwiftPackageReference section */+		A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */ = {isa = XCLocalSwiftPackageReference; relativePath = ../Packages/AsterismCore; };+/* End XCLocalSwiftPackageReference section */++/* Begin XCSwiftPackageProductDependency section */+		A10000000000000000000011 /* AsterismCore */ = {isa = XCSwiftPackageProductDependency; package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */; productName = AsterismCore; };+		A10000000000000000000012 /* AsterismCore */ = {isa = XCSwiftPackageProductDependency; package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */; productName = AsterismCore; };+		A10000000000000000000013 /* AsterismCore */ = {isa = XCSwiftPackageProductDependency; package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */; productName = AsterismCore; };+/* End XCSwiftPackageProductDependency section */ 	}; 	rootObject = D4A9C78C30091593004199A5 /* Project object */; }
Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme Added +22 / -0
diff --git a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcschemenew file mode 100644index 0000000..d31c0c4--- /dev/null+++ b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme	@@ -0,0 +1,22 @@+<?xml version="1.0" encoding="UTF-8"?>+<Scheme LastUpgradeVersion="2660" version="1.7">+  <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">+    <BuildActionEntries>+      <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">+        <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/>+      </BuildActionEntry>+    </BuildActionEntries>+  </BuildAction>+    <TestAction buildConfiguration="Development" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">+    <Testables>+      <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7A430091595004199A5" BuildableName="AsterismTests.xctest" BlueprintName="AsterismTests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference>+      <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7AE30091595004199A5" BuildableName="AsterismUITests.xctest" BlueprintName="AsterismUITests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference>+    </Testables>+  </TestAction>+  <LaunchAction buildConfiguration="Development" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">+    <BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/></BuildableProductRunnable>+  </LaunchAction>+  <ProfileAction buildConfiguration="Development" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"/>+  <AnalyzeAction buildConfiguration="Development"/>+  <ArchiveAction buildConfiguration="Development" revealArchiveInOrganizer="YES"/>+</Scheme>
Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme Added +22 / -0
diff --git a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcschemenew file mode 100644index 0000000..582f04c--- /dev/null+++ b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme	@@ -0,0 +1,22 @@+<?xml version="1.0" encoding="UTF-8"?>+<Scheme LastUpgradeVersion="2660" version="1.7">+  <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">+    <BuildActionEntries>+      <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">+        <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/>+      </BuildActionEntry>+    </BuildActionEntries>+  </BuildAction>+  <TestAction buildConfiguration="Personal" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">+    <Testables>+      <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7A430091595004199A5" BuildableName="AsterismTests.xctest" BlueprintName="AsterismTests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference>+      <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7AE30091595004199A5" BuildableName="AsterismUITests.xctest" BlueprintName="AsterismUITests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference>+    </Testables>+  </TestAction>+  <LaunchAction buildConfiguration="Personal" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">+    <BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/></BuildableProductRunnable>+  </LaunchAction>+  <ProfileAction buildConfiguration="Personal" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"/>+  <AnalyzeAction buildConfiguration="Personal"/>+  <ArchiveAction buildConfiguration="Personal" revealArchiveInOrganizer="YES"/>+</Scheme>
Asterism/Asterism/Asterism.Development.entitlements Added +5 / -0
diff --git a/Asterism/Asterism/Asterism.Development.entitlements b/Asterism/Asterism/Asterism.Development.entitlementsnew file mode 100644index 0000000..baf648a--- /dev/null+++ b/Asterism/Asterism/Asterism.Development.entitlements@@ -0,0 +1,5 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0"><dict>+<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism.dev</string></array>+</dict></plist>
Asterism/Asterism/Asterism.Personal.entitlements Added +5 / -0
diff --git a/Asterism/Asterism/Asterism.Personal.entitlements b/Asterism/Asterism/Asterism.Personal.entitlementsnew file mode 100644index 0000000..8fa6706--- /dev/null+++ b/Asterism/Asterism/Asterism.Personal.entitlements@@ -0,0 +1,5 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0"><dict>+<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism</string></array>+</dict></plist>
Asterism/Asterism/Asterism.entitlements Deleted +0 / -16
diff --git a/Asterism/Asterism/Asterism.entitlements b/Asterism/Asterism/Asterism.entitlementsdeleted file mode 100644index fd569cf..0000000--- a/Asterism/Asterism/Asterism.entitlements+++ /dev/null@@ -1,16 +0,0 @@-<?xml version="1.0" encoding="UTF-8"?>-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">-<plist version="1.0">-<dict>-	<key>aps-environment</key>-	<string>development</string>-	<key>com.apple.developer.aps-environment</key>-	<string>development</string>-	<key>com.apple.developer.icloud-container-identifiers</key>-	<array/>-	<key>com.apple.developer.icloud-services</key>-	<array>-		<string>CloudKit</string>-	</array>-</dict>-</plist>
Asterism/Asterism/AsterismApp.swift Modified +1 / -15
diff --git a/Asterism/Asterism/AsterismApp.swift b/Asterism/Asterism/AsterismApp.swiftindex 162e19f..6e4b381 100644--- a/Asterism/Asterism/AsterismApp.swift+++ b/Asterism/Asterism/AsterismApp.swift@@ -5,28 +5,14 @@ //  Created by Arjen Schwarz on 16/7/2026. // +import AsterismCore import SwiftUI-import SwiftData  @main struct AsterismApp: App {-    var sharedModelContainer: ModelContainer = {-        let schema = Schema([-            Item.self,-        ])-        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)--        do {-            return try ModelContainer(for: schema, configurations: [modelConfiguration])-        } catch {-            fatalError("Could not create ModelContainer: \(error)")-        }-    }()-     var body: some Scene {         WindowGroup {             ContentView()         }-        .modelContainer(sharedModelContainer)     } }
Asterism/Asterism/ContentView.swift Modified +154 / -55
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 4cfcbfe..5dbbafa 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -1,80 +1,179 @@-//-//  ContentView.swift-//  Asterism-//-//  Created by Arjen Schwarz on 16/7/2026.-//-+import AsterismCore import SwiftUI-import SwiftData +/// Two-tab root view driven by AppLibraryModel state. struct ContentView: View {-    @Environment(\.modelContext) private var modelContext-    @Query private var items: [Item]+    @State private var model: AppLibraryModel+    @State private var selectedTab: AppTab = .recent+    @State private var selectedRecentEntryID: UUID?+    @State private var selectedWorksEntryID: UUID?+    @State private var selectedWorkID: UUID?+    @State private var showingNewWork = false+    @State private var showingMoveTo: UUID?+    @State private var showingSettings = false++    enum AppTab {+        case recent+        case works+    }++    /// Production initializer. Debug UI tests may request an isolated seeded+    /// library; malformed requests render unavailable rather than touching an App Group.+    init() {+        #if DEBUG+        switch UITestLaunchSupport.request() {+        case .disabled:+            _model = State(initialValue: AppLibraryModel(environment: .current))+        case .seeded(let configuration):+            _model = State(+                initialValue: AppLibraryModel(+                    configuration: configuration,+                    seedUITestFixture: true+                )+            )+        case .invalid(let message):+            _model = State(initialValue: AppLibraryModel(startupFailureMessage: message))+        }+        #else+        _model = State(initialValue: AppLibraryModel(environment: .current))+        #endif+    }++    /// Test/Preview initializer — injects an explicit configuration.+    init(configuration: LibraryConfiguration) {+        _model = State(initialValue: AppLibraryModel(configuration: configuration))+    }      var body: some View {-        NavigationViewWrapper {-            List {-                ForEach(items) { item in-                    NavigationLink {-                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")-                    } label: {-                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))+        Group {+            switch model.state {+            case .loading:+                ProgressView("Opening library…")+                    .accessibilityIdentifier("app-loading")++            case .unavailable(let message):+                ContentUnavailableView {+                    Label("Library Unavailable", systemImage: "exclamationmark.triangle")+                } description: {+                    Text(message)+                } actions: {+                    Button("Retry") {+                        Task { await model.retry() }                     }+                    .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("app-retry-button")                 }-                .onDelete(perform: deleteItems)+                .accessibilityIdentifier("app-unavailable")++            case .ready:+                readyContent             }-#if os(macOS)-            .navigationSplitViewColumnWidth(min: 180, ideal: 200)-#endif-            .toolbar {-#if os(iOS)-                ToolbarItem(placement: .navigationBarTrailing) {-                    EditButton()+        }+        .task {+            await model.bootstrap()+        }+        .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in+            Task { await model.handleActivation() }+        }+    }++    private var readyContent: some View {+        TabView(selection: $selectedTab) {+            SwiftUI.Tab("Recent", systemImage: "clock", value: AppTab.recent) {+                NavigationStack {+                    RecentView(groups: model.recentGroups) { entryID in+                        selectedRecentEntryID = entryID+                    }+                    .navigationTitle("Recent")+                    .toolbar {+                        ToolbarItem(placement: .topBarTrailing) {+                            Button {+                                showingSettings = true+                            } label: {+                                Image(systemName: "gearshape")+                                    .frame(+                                        minWidth: AsterismLayout.minHitTarget,+                                        minHeight: AsterismLayout.minHitTarget+                                    )+                            }+                            .accessibilityIdentifier("settings-button")+                            .accessibilityLabel("Settings")+                        }+                    }+                    .navigationDestination(item: $selectedRecentEntryID) { entryID in+                        entryDetail(for: entryID)+                    }                 }-#endif-                ToolbarItem {-                    Button(action: addItem) {-                        Label("Add Item", systemImage: "plus")+            }+            .accessibilityIdentifier("tab-recent")++            SwiftUI.Tab("Works", systemImage: "sparkles", value: AppTab.works) {+                NavigationStack {+                    WorksView(+                        snapshot: model.worksSnapshot,+                        onSelectWork: { selectedWorkID = $0 },+                        onSelectEntry: { selectedWorksEntryID = $0 },+                        onNewWork: { showingNewWork = true }+                    )+                    .navigationTitle("Works")+                    .navigationDestination(item: $selectedWorkID) { workID in+                        if let detailModel = model.workDetailModel(for: workID) {+                            WorkDetailView(model: detailModel)+                        }+                    }+                    .navigationDestination(item: $selectedWorksEntryID) { entryID in+                        entryDetail(for: entryID)                     }                 }             }+            .accessibilityIdentifier("tab-works")         }-    }--    private func addItem() {-        withAnimation {-            let newItem = Item(timestamp: Date())-            modelContext.insert(newItem)+        .sheet(isPresented: $showingNewWork) {+            if let newWorkModel = model.newWorkModel() {+                NewWorkView(model: newWorkModel)+            }         }-    }--    private func deleteItems(offsets: IndexSet) {-        withAnimation {-            for index in offsets {-                modelContext.delete(items[index])+        .sheet(+            isPresented: Binding(+                get: { showingMoveTo != nil },+                set: { if !$0 { showingMoveTo = nil } }+            )+        ) {+            if let entryID = showingMoveTo,+               let moveToModel = model.moveToModel(for: entryID) {+                MoveToView(model: moveToModel)+            }+        }+        .sheet(isPresented: $showingSettings) {+            NavigationStack {+                if let backupModel = model.settingsBackupModel() {+                    SettingsView(model: backupModel)+                        .toolbar {+                            ToolbarItem(placement: .confirmationAction) {+                                Button("Done") { showingSettings = false }+                                    .frame(+                                        minWidth: AsterismLayout.minHitTarget,+                                        minHeight: AsterismLayout.minHitTarget+                                    )+                                    .accessibilityIdentifier("settings-done-button")+                            }+                        }+                }             }         }     }-} -fileprivate struct NavigationViewWrapper<Content: View>: View {-    let content: () -> Content--    var body: some View {-#if os(macOS)-        NavigationSplitView {-            content()-        } detail: {-            Text("Select an item")+    @ViewBuilder+    private func entryDetail(for entryID: UUID) -> some View {+        if let detailModel = model.entryDetailModel(for: entryID) {+            EntryDetailView(+                model: detailModel,+                onMoveTo: { showingMoveTo = entryID }+            )         }-#else-        content()-#endif     } }  #Preview {     ContentView()-        .modelContainer(for: Item.self, inMemory: true) }
Asterism/Asterism/Info.plist Modified +4 / -4
diff --git a/Asterism/Asterism/Info.plist b/Asterism/Asterism/Info.plistindex ca9a074..2899649 100644--- a/Asterism/Asterism/Info.plist+++ b/Asterism/Asterism/Info.plist@@ -2,9 +2,9 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict>-	<key>UIBackgroundModes</key>-	<array>-		<string>remote-notification</string>-	</array>+    <key>AsterismAppGroupIdentifier</key>+    <string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>+    <key>AsterismStoreRelativePath</key>+    <string>$(ASTERISM_STORE_RELATIVE_PATH)</string> </dict> </plist>
Asterism/Asterism/Item.swift Deleted +0 / -18
diff --git a/Asterism/Asterism/Item.swift b/Asterism/Asterism/Item.swiftdeleted file mode 100644index e2197a9..0000000--- a/Asterism/Asterism/Item.swift+++ /dev/null@@ -1,18 +0,0 @@-//-//  Item.swift-//  Asterism-//-//  Created by Arjen Schwarz on 16/7/2026.-//--import Foundation-import SwiftData--@Model-final class Item {-    var timestamp: Date-    -    init(timestamp: Date) {-        self.timestamp = timestamp-    }-}
Asterism/Asterism/Style/AsterismStyle.swift Added +54 / -0
diff --git a/Asterism/Asterism/Style/AsterismStyle.swift b/Asterism/Asterism/Style/AsterismStyle.swiftnew file mode 100644index 0000000..1cece8b--- /dev/null+++ b/Asterism/Asterism/Style/AsterismStyle.swift@@ -0,0 +1,54 @@+import SwiftUI++/// Semantic color tokens for the Constellation design language.+/// See docs/asterism-style-guide.md §2 for oklch definitions.+enum AsterismColors {+    // MARK: - Accent: Cyan (primary/positive)++    static let cyanDark = Color(red: 0.45, green: 0.82, blue: 0.92)+    static let cyanLight = Color(red: 0.10, green: 0.45, blue: 0.58)+    static var cyan: Color { cyanDark }++    // MARK: - Accent: Violet (secondary/negative)++    static let violetDark = Color(red: 0.78, green: 0.52, blue: 0.92)+    static let violetLight = Color(red: 0.42, green: 0.18, blue: 0.58)+    static var violet: Color { violetDark }++    // MARK: - Accent: Amber (teaching only)++    static let amberDark = Color(red: 0.92, green: 0.78, blue: 0.35)+    static let amberLight = Color(red: 0.58, green: 0.45, blue: 0.12)++    // MARK: - Neutrals++    static let primaryTextDark = Color(red: 0.91, green: 0.93, blue: 0.96)+    static let primaryTextLight = Color(red: 0.11, green: 0.12, blue: 0.17)+    static let secondaryTextDark = Color(red: 0.56, green: 0.59, blue: 0.68)+    static let secondaryTextLight = Color(red: 0.40, green: 0.43, blue: 0.51)+    static let noteTextDark = Color(red: 0.71, green: 0.74, blue: 0.82)+    static let noteTextLight = Color(red: 0.24, green: 0.26, blue: 0.34)++    // MARK: - Opaque fallbacks (Reduce Transparency)++    static let opaqueCardDark = Color(red: 0.07, green: 0.09, blue: 0.16)+    static let opaqueCardLight = Color(red: 0.95, green: 0.95, blue: 0.97)+}++/// Semantic typography helpers.+enum AsterismTypography {+    /// Serif font for work titles and screen titles (New York / SF Serif).+    static func serifTitle(_ size: CGFloat, weight: Font.Weight = .medium) -> Font {+        .system(size: size, weight: weight, design: .serif)+    }++    /// Section header style: 11pt, bold, uppercase.+    static let sectionHeader: Font = .system(size: 11, weight: .bold, design: .default)+}++/// Common shape/sizing constants.+enum AsterismLayout {+    static let cardRadius: CGFloat = 20+    static let buttonRadius: CGFloat = 21+    static let minHitTarget: CGFloat = 44+}
Asterism/Asterism/UITestLaunchSupport.swift Added +66 / -0
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftnew file mode 100644index 0000000..c69384b--- /dev/null+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -0,0 +1,66 @@+import AsterismCore+import Foundation++/// Injectable process-environment boundary used by the debug UI-test launcher.+/// Production launch does not consult test variables outside DEBUG builds.+protocol ProcessEnvironmentProviding: Sendable {+    var environment: [String: String] { get }+}++struct SystemProcessEnvironment: ProcessEnvironmentProviding {+    var environment: [String: String] { ProcessInfo.processInfo.environment }+}++enum UITestLaunchRequest: Equatable {+    case disabled+    case seeded(configuration: LibraryConfiguration)+    case invalid(message: String)+}++/// Resolves an isolated, disposable library only for an explicitly requested+/// UI-test scenario. Invalid input fails closed instead of opening an App Group.+enum UITestLaunchSupport {+    static let scenarioKey = "ASTERISM_UI_TEST_SCENARIO"+    static let runIDKey = "ASTERISM_UI_TEST_RUN_ID"+    static let seededScenario = "seeded-m1"++    static func request(+        environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment(),+        temporaryDirectory: URL = FileManager.default.temporaryDirectory+    ) -> UITestLaunchRequest {+        let environment = environmentProvider.environment+        guard let scenario = environment[scenarioKey] else {+            return .disabled+        }+        guard scenario == seededScenario else {+            return .invalid(message: "Unsupported UI test scenario.")+        }+        guard+            let rawRunID = environment[runIDKey],+            rawRunID.utf8.count == 36,+            let runID = UUID(uuidString: rawRunID)+        else {+            return .invalid(message: "Invalid UI test run identifier.")+        }++        let baseDirectory = temporaryDirectory+            .appending(path: "AsterismUITests", directoryHint: .isDirectory)+            .standardizedFileURL+        let rootDirectory = baseDirectory+            .appending(path: runID.uuidString, directoryHint: .isDirectory)+            .standardizedFileURL++        // UUID validation prevents path traversal; retain a containment check so+        // future identifier changes cannot accidentally weaken that boundary.+        guard rootDirectory.path.hasPrefix(baseDirectory.path + "/") else {+            return .invalid(message: "UI test library path escaped its temporary root.")+        }++        return .seeded(+            configuration: LibraryConfiguration(+                rootDirectory: rootDirectory,+                environment: .development+            )+        )+    }+}
Asterism/Asterism/ViewModels/AppLibraryModel.swift Added +206 / -0
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftnew file mode 100644index 0000000..8218a67--- /dev/null+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -0,0 +1,206 @@+import AsterismCore+import Foundation+import OSLog++/// Root app state driving the loading/ready/unavailable lifecycle.+@MainActor @Observable+public final class AppLibraryModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "AppLibraryModel")++    public enum State: Equatable, Sendable {+        case loading+        case ready+        case unavailable(message: String)+    }++    public private(set) var state: State = .loading+    public private(set) var recentGroups: [DatedEntryGroup] = []+    public private(set) var worksSnapshot: WorksSnapshot = WorksSnapshot(works: [], unattachedEntries: [])++    /// When an explicit configuration is injected (tests), resolution is skipped.+    private let explicitConfiguration: LibraryConfiguration?+    /// When environment-based resolution is used, this holds the environment.+    private let environment: LibraryEnvironment?+    /// The locator used for App Group resolution (injectable for tests).+    private let locator: any SharedContainerLocating++    /// The resolved configuration after successful bootstrap.+    private var resolvedConfiguration: LibraryConfiguration?+    private var repository: (any LibraryProviding)?+    /// Retains the concrete repository for backup export (conforms to BackupSnapshotProviding).+    private var backupRepository: (any BackupSnapshotProviding)?+    /// 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.+    private let shouldSeedUITestFixture: Bool++    /// Production initializer: resolves configuration from the compile-time environment at bootstrap.+    /// Fails closed (→ unavailable) when App Group resolution fails.+    public init(+        environment: LibraryEnvironment,+        locator: any SharedContainerLocating = SystemSharedContainerLocator()+    ) {+        self.explicitConfiguration = nil+        self.environment = environment+        self.locator = locator+        self.startupFailureMessage = nil+        self.shouldSeedUITestFixture = false+    }++    /// Test/explicit initializer: bypasses locator resolution entirely.+    public init(configuration: LibraryConfiguration) {+        self.explicitConfiguration = configuration+        self.environment = nil+        self.locator = SystemSharedContainerLocator()+        self.startupFailureMessage = nil+        self.shouldSeedUITestFixture = false+    }++    /// Debug UI-test initializer. The caller must provide an isolated temporary configuration.+    init(configuration: LibraryConfiguration, seedUITestFixture: Bool) {+        self.explicitConfiguration = configuration+        self.environment = nil+        self.locator = SystemSharedContainerLocator()+        self.startupFailureMessage = nil+        self.shouldSeedUITestFixture = seedUITestFixture+    }++    /// Constructs a model that can only render an unavailable state.+    init(startupFailureMessage: String) {+        self.explicitConfiguration = nil+        self.environment = nil+        self.locator = SystemSharedContainerLocator()+        self.startupFailureMessage = startupFailureMessage+        self.shouldSeedUITestFixture = false+    }++    /// Attempts to open the library; transitions to ready or unavailable.+    public func bootstrap() async {+        state = .loading+        if let startupFailureMessage {+            state = .unavailable(message: startupFailureMessage)+            Self.logger.error("Library bootstrap blocked by invalid launch configuration")+            return+        }++        do {+            let configuration: LibraryConfiguration+            if let explicit = explicitConfiguration {+                configuration = explicit+            } else {+                guard let env = environment else {+                    state = .unavailable(message: "No library environment configured.")+                    return+                }+                configuration = try LibraryConfiguration.production(environment: env, locator: locator)+            }+            resolvedConfiguration = configuration+            let repo = try await LibraryRepository.open(configuration)+            if shouldSeedUITestFixture {+                try await seedUITestFixture(in: repo)+            }+            self.repository = repo+            self.backupRepository = repo+            await refreshAll()+            state = .ready+            Self.logger.debug("Library bootstrap completed")+        } catch {+            state = .unavailable(message: error.localizedDescription)+            Self.logger.error("Library bootstrap failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Retries the same bootstrap without changing paths.+    public func retry() async {+        await bootstrap()+    }++    /// Called when the app becomes active; refreshes all snapshots.+    public func handleActivation() async {+        guard state == .ready, repository != nil else { return }+        await refreshAll()+    }++    /// Replaces all cached snapshots from the repository.+    public func refreshAll() async {+        guard let repo = repository else { return }+        do {+            let calendar = Calendar.current+            recentGroups = try await repo.recentEntries(calendar: calendar)+            worksSnapshot = try await repo.works()+        } catch {+            Self.logger.error("Snapshot refresh failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Provides a detail model for a specific entry.+    public func entryDetailModel(for id: UUID) -> EntryDetailModel? {+        guard let repo = repository else { return nil }+        return EntryDetailModel(entryID: id, library: repo, onMutation: { [weak self] in+            await self?.refreshAll()+        })+    }++    /// Provides a detail model for a specific work.+    public func workDetailModel(for id: UUID) -> WorkDetailModel? {+        guard let repo = repository else { return nil }+        return WorkDetailModel(workID: id, library: repo, onMutation: { [weak self] in+            await self?.refreshAll()+        })+    }++    /// Provides a model for creating a new work.+    public func newWorkModel() -> NewWorkFormModel? {+        guard let repo = repository else { return nil }+        return NewWorkFormModel(library: repo, onMutation: { [weak self] in+            await self?.refreshAll()+        })+    }++    /// Provides a move-to model for an entry.+    public func moveToModel(for entryID: UUID) -> MoveToModel? {+        guard let repo = repository else { return nil }+        return MoveToModel(entryID: entryID, library: repo, onMutation: { [weak self] in+            await self?.refreshAll()+        })+    }++    /// Provides a settings backup model backed by the current repository.+    public func settingsBackupModel() -> SettingsBackupModel? {+        guard let repo = backupRepository, let config = resolvedConfiguration else { return nil }+        let stagingDir = config.rootDirectory+            .appending(path: "Library/Caches/BackupExports")+        let exporter = BackupExporter(repository: repo, stagingDirectory: stagingDir)+        return SettingsBackupModel(exporter: exporter)+    }++    /// Creates the smallest graph that makes every M1 app journey reachable.+    /// Refuse to seed a non-empty location so a malformed test launch can never+    /// overwrite or blend with an existing library.+    private func seedUITestFixture(in repository: LibraryRepository) async throws {+        let counts = try await repository.debugCounts()+        guard counts == .zero else {+            throw LibraryRepositoryError.invalidInput(+                operation: "preparing UI test fixture",+                reason: "temporary library was not empty"+            )+        }++        _ = try await repository.capture(+            CaptureDraft(+                captureTitle: "Integration Chapter",+                captureTitleSource: .manual,+                rawURLString: "https://integration.test/chapter/1",+                note: "Seeded note for accessibility journeys",+                rating: .up+            )+        )+        _ = try await repository.createWork(+            NewWorkDraft(+                displayTitle: "Integration Work",+                hostname: "integration.test"+            )+        )+        Self.logger.debug("Seeded isolated M1 UI test fixture")+    }+}
Asterism/Asterism/ViewModels/EntryDetailModel.swift Added +89 / -0
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftnew file mode 100644index 0000000..b3308ee--- /dev/null+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -0,0 +1,89 @@+import AsterismCore+import Foundation+import OSLog++/// View model for Entry detail: manages drafts for note/rating and delete.+@MainActor @Observable+public final class EntryDetailModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "EntryDetailModel")++    public enum State: Equatable, Sendable {+        case loading+        case ready+        case submitting+        case error(message: String)+    }++    public private(set) var state: State = .loading+    public private(set) var entry: EntrySnapshot?+    public private(set) var errorMessage: String?++    // Draft fields+    public var draftNote: String = ""+    public var draftRating: Rating?++    private let entryID: UUID+    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private var isSubmitting = false++    public init(entryID: UUID, library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+        self.entryID = entryID+        self.library = library+        self.onMutation = onMutation+    }++    public func load() async {+        state = .loading+        do {+            let snapshot = try await library.entry(id: entryID)+            self.entry = snapshot+            self.draftNote = snapshot.note+            self.draftRating = snapshot.rating+            state = .ready+        } catch {+            state = .error(message: error.localizedDescription)+            Self.logger.error("Entry load failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Commits note/rating edit. Suppresses duplicate submissions.+    public func update() async {+        guard !isSubmitting else { return }+        isSubmitting = true+        state = .submitting+        errorMessage = nil+        do {+            try await library.updateEntry(id: entryID, note: draftNote, rating: draftRating)+            await onMutation()+            await load()+        } catch {+            errorMessage = error.localizedDescription+            // Restore prior snapshot state — no optimistic mutation+            if let entry { draftNote = entry.note; draftRating = entry.rating }+            state = .error(message: error.localizedDescription)+            Self.logger.error("Entry update failed: \(String(describing: error), privacy: .public)")+        }+        isSubmitting = false+    }++    /// Deletes this entry. Suppresses duplicate submissions.+    public func delete() async {+        guard !isSubmitting else { return }+        isSubmitting = true+        state = .submitting+        errorMessage = nil+        do {+            try await library.deleteEntry(id: entryID)+            await onMutation()+            // Entry no longer exists; detail should dismiss+            state = .ready+            entry = nil+        } catch {+            errorMessage = error.localizedDescription+            state = .error(message: error.localizedDescription)+            Self.logger.error("Entry delete failed: \(String(describing: error), privacy: .public)")+        }+        isSubmitting = false+    }+}
Asterism/Asterism/ViewModels/MoveToModel.swift Added +77 / -0
diff --git a/Asterism/Asterism/ViewModels/MoveToModel.swift b/Asterism/Asterism/ViewModels/MoveToModel.swiftnew file mode 100644index 0000000..dce0a8b--- /dev/null+++ b/Asterism/Asterism/ViewModels/MoveToModel.swift@@ -0,0 +1,77 @@+import AsterismCore+import Foundation+import OSLog++/// View model for the Move-to action: same-host Works, new Work, leave unattached.+@MainActor @Observable+public final class MoveToModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "MoveToModel")++    public enum State: Equatable, Sendable {+        case loading+        case ready+        case submitting+        case completed+        case error(message: String)+    }++    public private(set) var state: State = .loading+    public private(set) var destinations: [WorkSnapshot] = []+    public private(set) var errorMessage: String?++    public var newWorkTitle: String = ""++    private let entryID: UUID+    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private var isSubmitting = false++    public init(entryID: UUID, library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+        self.entryID = entryID+        self.library = library+        self.onMutation = onMutation+    }++    public func load() async {+        state = .loading+        do {+            destinations = try await library.workDestinations(for: entryID)+            state = .ready+        } catch {+            state = .error(message: error.localizedDescription)+            Self.logger.error("Move-to destinations load failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Moves entry to an existing Work. Suppresses duplicate submissions.+    public func moveToExisting(_ workID: UUID) async {+        await submit(.existing(workID))+    }++    /// Moves entry to a new Work. Suppresses duplicate submissions.+    public func moveToNewWork() async {+        await submit(.newWork(displayTitle: newWorkTitle))+    }++    /// Leaves entry unattached. Suppresses duplicate submissions.+    public func leaveUnattached() async {+        await submit(.unattached)+    }++    private func submit(_ destination: WorkDestination) async {+        guard !isSubmitting else { return }+        isSubmitting = true+        state = .submitting+        errorMessage = nil+        do {+            try await library.moveEntry(entryID, to: destination)+            await onMutation()+            state = .completed+        } catch {+            errorMessage = error.localizedDescription+            state = .error(message: error.localizedDescription)+            Self.logger.error("Move-to failed: \(String(describing: error), privacy: .public)")+        }+        isSubmitting = false+    }+}
Asterism/Asterism/ViewModels/NewWorkFormModel.swift Added +51 / -0
diff --git a/Asterism/Asterism/ViewModels/NewWorkFormModel.swift b/Asterism/Asterism/ViewModels/NewWorkFormModel.swiftnew file mode 100644index 0000000..316bab8--- /dev/null+++ b/Asterism/Asterism/ViewModels/NewWorkFormModel.swift@@ -0,0 +1,51 @@+import AsterismCore+import Foundation+import OSLog++/// View model for creating a new Work from the Works list.+@MainActor @Observable+public final class NewWorkFormModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "NewWorkFormModel")++    public enum State: Equatable, Sendable {+        case editing+        case submitting+        case created(WorkSnapshot)+        case error(message: String)+    }++    public private(set) var state: State = .editing+    public private(set) var errorMessage: String?++    public var draftTitle: String = ""+    public var draftHostname: String = ""++    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private var isSubmitting = false++    public init(library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+        self.library = library+        self.onMutation = onMutation+    }++    /// Creates the Work from draft values. Suppresses duplicate submissions.+    public func create() async {+        guard !isSubmitting else { return }+        isSubmitting = true+        state = .submitting+        errorMessage = nil+        do {+            let draft = NewWorkDraft(displayTitle: draftTitle, hostname: draftHostname)+            let result = try await library.createWork(draft)+            await onMutation()+            state = .created(result)+            Self.logger.debug("Work created successfully")+        } catch {+            errorMessage = error.localizedDescription+            state = .error(message: error.localizedDescription)+            Self.logger.error("Work creation failed: \(String(describing: error), privacy: .public)")+        }+        isSubmitting = false+    }+}
Asterism/Asterism/ViewModels/SettingsBackupModel.swift Added +140 / -0
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftnew file mode 100644index 0000000..1ba6cad--- /dev/null+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -0,0 +1,140 @@+import AsterismCore+import Foundation+import OSLog++// MARK: - Backup Exporting Protocol++/// Test seam abstracting BackupExporter's three operations needed by the Settings surface.+/// Conforms BackupExporter to this protocol via extension below.+public protocol BackupExporting: Sendable {+    func export(metadata: BackupMetadata) async throws -> BackupExportResult+    func cleanup(_ result: BackupExportResult)+    func scavengeStaleFiles()+}++extension BackupExporter: BackupExporting {}++// MARK: - Settings Backup View Model++/// Drives the Settings backup surface: export progress, failure reporting, and+/// system share presentation. Does not add restore or markdown export.+@MainActor @Observable+public final class SettingsBackupModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "SettingsBackupModel")++    // MARK: - State++    public enum State: Equatable, Sendable {+        case idle+        case exporting+        case sharing+        case failed+    }++    public private(set) var state: State = .idle+    public private(set) var exportedFileURL: URL?+    public private(set) var errorMessage: String?++    // MARK: - Dependencies++    private let exporter: any BackupExporting+    private var isExporting = false+    private var currentResult: BackupExportResult?++    // MARK: - Init++    /// Creates the model and immediately scavenges any stale backup files from prior sessions.+    public init(exporter: any BackupExporting) {+        self.exporter = exporter+        // Clean up abandoned files older than 24 hours on startup+        exporter.scavengeStaleFiles()+    }++    // MARK: - Export++    /// Starts a backup export. Suppresses duplicate submissions while already in flight.+    /// On success, transitions to `.sharing` with the file URL ready for system share.+    /// On failure, transitions to `.failed` with a privacy-safe diagnostic message.+    public func startExport() async {+        guard !isExporting else { return }+        isExporting = true+        state = .exporting+        errorMessage = nil+        exportedFileURL = nil+        currentResult = nil++        do {+            let metadata = BackupMetadata(+                appBuild: Self.currentAppBuild(),+                databaseSchemaVersion: 1,+                exportedAt: Date()+            )+            let result = try await exporter.export(metadata: metadata)+            currentResult = result+            exportedFileURL = result.fileURL+            state = .sharing+            Self.logger.debug("Backup export succeeded")+        } catch {+            state = .failed+            // Privacy-safe: log only the error category, never user content+            errorMessage = Self.privacySafeMessage(for: error)+            Self.logger.error("Backup export failed: \(Self.diagnosticCategory(for: error), privacy: .public)")+        }++        isExporting = false+    }++    // MARK: - Share Lifecycle++    /// Called when the user completes sharing (dismiss/send). Cleans up the staged file.+    public func handleShareCompletion() {+        cleanupAndReset()+    }++    /// Called when the user cancels the share sheet. Cleans up the staged file.+    public func handleShareCancellation() {+        cleanupAndReset()+    }++    // MARK: - Private++    private func cleanupAndReset() {+        if let result = currentResult {+            exporter.cleanup(result)+        }+        currentResult = nil+        exportedFileURL = nil+        state = .idle+    }++    /// Returns the current app's build number from the main bundle, or "unknown" for tests.+    private static func currentAppBuild() -> String {+        Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"+    }++    /// Produces a user-facing error message that never leaks titles, notes, or URLs.+    private static func privacySafeMessage(for error: Error) -> String {+        switch error {+        case is BackupCodecError:+            "Backup export failed due to an encoding error. Please try again."+        case is BackupValidationError:+            "Backup validation failed. The export was not saved. Please try again."+        default:+            "Backup export failed. Please try again."+        }+    }++    /// Returns a short diagnostic category for structured logging (never includes user data).+    private static func diagnosticCategory(for error: Error) -> String {+        switch error {+        case let e as BackupCodecError:+            "codec: \(e)"+        case let e as BackupValidationError:+            "validation: \(e)"+        case let e as LibraryRepositoryError:+            "repository: \(e)"+        default:+            "unknown: \(type(of: error))"+        }+    }+}
Asterism/Asterism/ViewModels/WorkDetailModel.swift Added +84 / -0
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftnew file mode 100644index 0000000..f7be97c--- /dev/null+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -0,0 +1,84 @@+import AsterismCore+import Foundation+import OSLog++/// View model for Work detail: manages metadata editing.+@MainActor @Observable+public final class WorkDetailModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "WorkDetailModel")++    public enum State: Equatable, Sendable {+        case loading+        case ready+        case submitting+        case error(message: String)+    }++    public private(set) var state: State = .loading+    public private(set) var work: WorkSnapshot?+    public private(set) var errorMessage: String?++    // Draft fields+    public var draftTitle: String = ""+    public var draftType: WorkType = .other+    public var draftTags: [String] = []+    public var draftNotes: String = ""++    private let workID: UUID+    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private var isSubmitting = false++    public init(workID: UUID, library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+        self.workID = workID+        self.library = library+        self.onMutation = onMutation+    }++    public func load() async {+        state = .loading+        do {+            let snapshot = try await library.work(id: workID)+            self.work = snapshot+            self.draftTitle = snapshot.displayTitle+            self.draftType = snapshot.type+            self.draftTags = snapshot.genreTags+            self.draftNotes = snapshot.genericNotes+            state = .ready+        } catch {+            state = .error(message: error.localizedDescription)+            Self.logger.error("Work load failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Commits metadata edit. Suppresses duplicate submissions.+    public func save() async {+        guard !isSubmitting else { return }+        isSubmitting = true+        state = .submitting+        errorMessage = nil+        do {+            let draft = WorkMetadataDraft(+                displayTitle: draftTitle,+                type: draftType,+                genreTags: draftTags,+                genericNotes: draftNotes+            )+            try await library.updateWork(id: workID, draft: draft)+            await onMutation()+            await load()+        } catch {+            errorMessage = error.localizedDescription+            // No optimistic mutation: restore from prior snapshot+            if let work {+                draftTitle = work.displayTitle+                draftType = work.type+                draftTags = work.genreTags+                draftNotes = work.genericNotes+            }+            state = .error(message: error.localizedDescription)+            Self.logger.error("Work update failed: \(String(describing: error), privacy: .public)")+        }+        isSubmitting = false+    }+}
Asterism/Asterism/Views/EntryDetailView.swift Added +139 / -0
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftnew file mode 100644index 0000000..f4e2947--- /dev/null+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -0,0 +1,139 @@+import AsterismCore+import SwiftUI++/// Entry detail: shows entry metadata and provides note/rating editing, assignment, and delete.+struct EntryDetailView: View {+    @State private var model: EntryDetailModel+    @Environment(\.dismiss) private var dismiss+    let onMoveTo: () -> Void++    init(model: EntryDetailModel, onMoveTo: @escaping () -> Void) {+        _model = State(initialValue: model)+        self.onMoveTo = onMoveTo+    }++    var body: some View {+        Group {+            switch model.state {+            case .loading:+                ProgressView("Loading…")+                    .accessibilityIdentifier("entry-detail-loading")+            case .ready, .submitting, .error:+                if let entry = model.entry {+                    entryContent(entry)+                } else {+                    ContentUnavailableView(+                        "Entry deleted",+                        systemImage: "trash",+                        description: Text("This entry has been removed.")+                    )+                    .accessibilityIdentifier("entry-detail-deleted")+                }+            }+        }+        .navigationTitle("Entry")+        .task { await model.load() }+    }++    @ViewBuilder+    private func entryContent(_ entry: EntrySnapshot) -> some View {+        Form {+            Section("Details") {+                LabeledContent("Title") {+                    Text(entry.captureTitle)+                        .font(AsterismTypography.serifTitle(17))+                }+                .accessibilityIdentifier("entry-detail-title")++                LabeledContent("Site") {+                    Text(entry.hostname)+                }+                .accessibilityIdentifier("entry-detail-hostname")++                LabeledContent("Shared") {+                    Text(entry.lastSharedAt, style: .date)+                }+                .accessibilityIdentifier("entry-detail-shared")++                if let url = URL(string: entry.rawURLString) {+                    Link("Open URL", destination: url)+                        .frame(minHeight: AsterismLayout.minHitTarget)+                        .accessibilityIdentifier("entry-detail-link")+                }+            }++            Section("Note") {+                TextEditor(text: $model.draftNote)+                    .frame(minHeight: 80)+                    .accessibilityIdentifier("entry-detail-note-editor")+                    .accessibilityLabel("Entry note")+            }++            Section("Rating") {+                HStack(spacing: 16) {+                    ratingButton(.up)+                    ratingButton(.down)+                    Spacer()+                }+            }++            if let errorMessage = model.errorMessage {+                Section {+                    Text(errorMessage)+                        .foregroundStyle(.red)+                        .accessibilityIdentifier("entry-detail-error")+                }+            }++            Section {+                Button("Update") {+                    Task { await model.update() }+                }+                .disabled(model.state == .submitting)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("entry-detail-update-button")++                Button("Move to") {+                    onMoveTo()+                }+                .disabled(model.state == .submitting)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("entry-detail-move-to-button")++                Button("Delete Entry", role: .destructive) {+                    Task {+                        await model.delete()+                        if model.entry == nil { dismiss() }+                    }+                }+                .disabled(model.state == .submitting)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("entry-detail-delete-button")+            }+        }+    }++    @ViewBuilder+    private func ratingButton(_ rating: Rating) -> some View {+        let isSelected = model.draftRating == rating+        let label = rating == .up ? "▲" : "▼"+        let color = rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark++        Button {+            if model.draftRating == rating {+                model.draftRating = nil+            } else {+                model.draftRating = rating+            }+        } label: {+            Text(label)+                .font(.title3)+                .foregroundStyle(isSelected ? color : .secondary)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("entry-detail-rating-\(rating.rawValue)")+        .accessibilityLabel(rating == .up ? "Rate up" : "Rate down")+        .accessibilityAddTraits(isSelected ? .isSelected : [])+    }+}
Asterism/Asterism/Views/MoveToView.swift Added +102 / -0
diff --git a/Asterism/Asterism/Views/MoveToView.swift b/Asterism/Asterism/Views/MoveToView.swiftnew file mode 100644index 0000000..06fd2e7--- /dev/null+++ b/Asterism/Asterism/Views/MoveToView.swift@@ -0,0 +1,102 @@+import AsterismCore+import SwiftUI++/// Move-to sheet: same-host Works, New Work, and Leave unattached.+struct MoveToView: View {+    @State private var model: MoveToModel+    @Environment(\.dismiss) private var dismiss++    init(model: MoveToModel) {+        _model = State(initialValue: model)+    }++    var body: some View {+        NavigationStack {+            Group {+                switch model.state {+                case .loading:+                    ProgressView("Loading destinations…")+                        .accessibilityIdentifier("move-to-loading")+                case .ready, .submitting, .error:+                    destinationsList+                case .completed:+                    // Auto-dismiss handled below+                    EmptyView()+                }+            }+            .navigationTitle("Move to")+            .toolbar {+                ToolbarItem(placement: .cancellationAction) {+                    Button("Cancel") { dismiss() }+                        .accessibilityIdentifier("move-to-cancel-button")+                }+            }+            .task { await model.load() }+            .onChange(of: model.state) { _, newState in+                if case .completed = newState { dismiss() }+            }+        }+    }++    @ViewBuilder+    private var destinationsList: some View {+        List {+            if !model.destinations.isEmpty {+                Section("Same-host Works") {+                    ForEach(model.destinations, id: \.id) { work in+                        Button {+                            Task { await model.moveToExisting(work.id) }+                        } label: {+                            Text(work.displayTitle)+                                .font(AsterismTypography.serifTitle(15))+                        }+                        .disabled(model.state == .submitting)+                        .frame(+                            minWidth: AsterismLayout.minHitTarget,+                            minHeight: AsterismLayout.minHitTarget+                        )+                        .accessibilityIdentifier("move-to-work-destination")+                        .accessibilityLabel("Move to \(work.displayTitle)")+                    }+                }+            }++            Section {+                HStack {+                    TextField("New Work title", text: $model.newWorkTitle)+                        .accessibilityIdentifier("move-to-new-work-title")+                    Button {+                        Task { await model.moveToNewWork() }+                    } label: {+                        Text("New Work")+                            .frame(+                                minWidth: AsterismLayout.minHitTarget,+                                minHeight: AsterismLayout.minHitTarget+                            )+                            .contentShape(Rectangle())+                    }+                    .disabled(model.state == .submitting || model.newWorkTitle.trimmingCharacters(in: .whitespaces).isEmpty)+                    .accessibilityIdentifier("move-to-new-work-button")+                }+                .frame(minHeight: AsterismLayout.minHitTarget)+            }++            Section {+                Button("Leave unattached") {+                    Task { await model.leaveUnattached() }+                }+                .disabled(model.state == .submitting)+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("move-to-leave-unattached-button")+            }++            if let errorMessage = model.errorMessage {+                Section {+                    Text(errorMessage)+                        .foregroundStyle(.red)+                        .accessibilityIdentifier("move-to-error")+                }+            }+        }+    }+}
Asterism/Asterism/Views/NewWorkView.swift Added +55 / -0
diff --git a/Asterism/Asterism/Views/NewWorkView.swift b/Asterism/Asterism/Views/NewWorkView.swiftnew file mode 100644index 0000000..8e484b3--- /dev/null+++ b/Asterism/Asterism/Views/NewWorkView.swift@@ -0,0 +1,55 @@+import AsterismCore+import SwiftUI++/// New Work form: title and hostname fields with explicit Create action.+struct NewWorkView: View {+    @State private var model: NewWorkFormModel+    @Environment(\.dismiss) private var dismiss++    init(model: NewWorkFormModel) {+        _model = State(initialValue: model)+    }++    var body: some View {+        NavigationStack {+            Form {+                Section("New Work") {+                    TextField("Title", text: $model.draftTitle)+                        .accessibilityIdentifier("new-work-title-field")++                    TextField("Hostname (e.g. example.com)", text: $model.draftHostname)+                        .textInputAutocapitalization(.never)+                        .autocorrectionDisabled()+                        .accessibilityIdentifier("new-work-hostname-field")+                }++                if let errorMessage = model.errorMessage {+                    Section {+                        Text(errorMessage)+                            .foregroundStyle(.red)+                            .accessibilityIdentifier("new-work-error")+                    }+                }++                Section {+                    Button("Create") {+                        Task {+                            await model.create()+                            if case .created = model.state { dismiss() }+                        }+                    }+                    .disabled(model.state == .submitting)+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("new-work-create-button")+                }+            }+            .navigationTitle("New Work")+            .toolbar {+                ToolbarItem(placement: .cancellationAction) {+                    Button("Cancel") { dismiss() }+                        .accessibilityIdentifier("new-work-cancel-button")+                }+            }+        }+    }+}
Asterism/Asterism/Views/RecentView.swift Added +91 / -0
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftnew file mode 100644index 0000000..264668e--- /dev/null+++ b/Asterism/Asterism/Views/RecentView.swift@@ -0,0 +1,91 @@+import AsterismCore+import SwiftUI++/// Recent tab: shows all entries grouped by day, ordered by lastSharedAt descending.+struct RecentView: View {+    let groups: [DatedEntryGroup]+    let onSelect: (UUID) -> Void++    var body: some View {+        if groups.isEmpty {+            ContentUnavailableView(+                "No captures yet",+                systemImage: "clock",+                description: Text("Share a page to Asterism to begin.")+            )+            .accessibilityIdentifier("recent-empty")+        } else {+            List {+                ForEach(groups, id: \.day) { group in+                    Section {+                        ForEach(group.entries, id: \.id) { entry in+                            Button {+                                onSelect(entry.id)+                            } label: {+                                RecentEntryRow(entry: entry)+                                    .contentShape(Rectangle())+                            }+                            .buttonStyle(.plain)+                            .accessibilityIdentifier("recent-entry-\(entry.id.uuidString)")+                            .accessibilityLabel("Open entry \(entry.captureTitle) from \(entry.hostname)")+                        }+                    } header: {+                        Text(group.day, style: .date)+                            .font(AsterismTypography.sectionHeader)+                            .textCase(.uppercase)+                            .accessibilityIdentifier("recent-section-header")+                    }+                }+            }+            .listStyle(.plain)+            .accessibilityIdentifier("recent-list")+        }+    }+}++/// A single row in the Recent list: raw capture title, hostname, note preview,+/// last-shared time, and rating. Normal treatment in M1 — no amber.+struct RecentEntryRow: View {+    let entry: EntrySnapshot++    var body: some View {+        VStack(alignment: .leading, spacing: 4) {+            // Title in serif (work title style for raw capture title)+            Text(entry.captureTitle)+                .font(AsterismTypography.serifTitle(15))+                .lineLimit(1)+                .accessibilityIdentifier("entry-title")++            HStack(spacing: 6) {+                Text(entry.hostname)+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("entry-hostname")++                Spacer()++                if let rating = entry.rating {+                    Text(rating == .up ? "▲" : "▼")+                        .font(.caption)+                        .foregroundStyle(rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark)+                        .accessibilityIdentifier("entry-rating")+                }++                Text(entry.lastSharedAt, style: .time)+                    .font(.caption2)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("entry-time")+            }++            if !entry.note.isEmpty {+                Text(entry.note)+                    .font(.caption)+                    .foregroundStyle(.tertiary)+                    .lineLimit(2)+                    .accessibilityIdentifier("entry-note-preview")+            }+        }+        .padding(.vertical, 4)+        .frame(minHeight: AsterismLayout.minHitTarget)+    }+}
Asterism/Asterism/Views/SettingsView.swift Added +120 / -0
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftnew file mode 100644index 0000000..316ba44--- /dev/null+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -0,0 +1,120 @@+import SwiftUI++/// Settings surface presenting the backup export action with progress, failure,+/// and system share presentation. Omits restore and markdown export per M1 scope.+struct SettingsView: View {+    @State private var model: SettingsBackupModel+    @State private var showingShareSheet = false++    init(model: SettingsBackupModel) {+        _model = State(initialValue: model)+    }++    var body: some View {+        List {+            Section {+                backupRow+            } header: {+                Text("Data")+            }+        }+        .navigationTitle("Settings")+        .accessibilityIdentifier("settings-view")+        .sheet(isPresented: $showingShareSheet, onDismiss: handleShareDismiss) {+            if let url = model.exportedFileURL {+                ShareSheet(fileURL: url)+                    .accessibilityIdentifier("settings-backup-share-sheet")+            }+        }+        .onChange(of: model.state) { _, newState in+            if newState == .sharing {+                showingShareSheet = true+            }+        }+    }++    // MARK: - Backup Row++    @ViewBuilder+    private var backupRow: some View {+        switch model.state {+        case .idle:+            Button {+                Task { await model.startExport() }+            } label: {+                Label("Export Backup", systemImage: "arrow.up.doc")+            }+            .accessibilityIdentifier("settings-export-backup-button")++        case .exporting:+            HStack {+                ProgressView()+                    .accessibilityIdentifier("settings-backup-progress")+                Text("Preparing backup…")+                    .foregroundStyle(.secondary)+            }+            .accessibilityIdentifier("settings-backup-exporting")++        case .sharing:+            HStack {+                Image(systemName: "checkmark.circle.fill")+                    .foregroundStyle(AsterismColors.cyanDark)+                Text("Backup ready")+                    .foregroundStyle(.secondary)+            }+            .accessibilityIdentifier("settings-backup-sharing")++        case .failed:+            VStack(alignment: .leading, spacing: 8) {+                HStack {+                    Image(systemName: "exclamationmark.triangle.fill")+                        .foregroundStyle(.red)+                    Text("Backup Failed")+                }+                .accessibilityIdentifier("settings-backup-failed")++                if let message = model.errorMessage {+                    Text(message)+                        .font(.caption)+                        .foregroundStyle(.secondary)+                        .accessibilityIdentifier("settings-backup-error-message")+                }++                Button {+                    Task { await model.startExport() }+                } label: {+                    Text("Try Again")+                }+                .accessibilityIdentifier("settings-backup-retry-button")+            }+        }+    }++    // MARK: - Share Sheet Lifecycle++    /// Handles share sheet dismissal — whether the user completed or cancelled.+    private func handleShareDismiss() {+        // The system share sheet dismissal covers both completion and cancellation.+        // Either way, clean up the staged file and return to idle.+        if model.state == .sharing {+            model.handleShareCompletion()+        }+    }+}++// MARK: - System Share Sheet (UIKit bridge)++/// Wraps UIActivityViewController for presenting the backup file through the system share interface.+private struct ShareSheet: UIViewControllerRepresentable {+    let fileURL: URL++    func makeUIViewController(context: Context) -> UIActivityViewController {+        let controller = UIActivityViewController(+            activityItems: [fileURL],+            applicationActivities: nil+        )+        return controller+    }++    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}+}
Asterism/Asterism/Views/WorkDetailView.swift Added +95 / -0
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftnew file mode 100644index 0000000..ac0ade3--- /dev/null+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -0,0 +1,95 @@+import AsterismCore+import SwiftUI++/// Work detail: shows metadata, assigned entries, and editing controls.+struct WorkDetailView: View {+    @State private var model: WorkDetailModel++    init(model: WorkDetailModel) {+        _model = State(initialValue: model)+    }++    var body: some View {+        Group {+            switch model.state {+            case .loading:+                ProgressView("Loading…")+                    .accessibilityIdentifier("work-detail-loading")+            case .ready, .submitting, .error:+                if let work = model.work {+                    workContent(work)+                } else {+                    ContentUnavailableView("Work not found", systemImage: "questionmark.circle")+                }+            }+        }+        .navigationTitle("Work")+        .task { await model.load() }+    }++    @ViewBuilder+    private func workContent(_ work: WorkSnapshot) -> some View {+        Form {+            Section("Metadata") {+                TextField("Title", text: $model.draftTitle)+                    .font(AsterismTypography.serifTitle(17))+                    .accessibilityIdentifier("work-detail-title-field")++                LabeledContent("Site") {+                    Text(work.siteHostname)+                }+                .accessibilityIdentifier("work-detail-hostname")++                Picker("Type", selection: $model.draftType) {+                    ForEach(WorkType.allCases, id: \.self) { type in+                        Text(type.rawValue).tag(type)+                    }+                }+                .accessibilityIdentifier("work-detail-type-picker")++                TextField("Notes", text: $model.draftNotes, axis: .vertical)+                    .lineLimit(3...6)+                    .accessibilityIdentifier("work-detail-notes-field")+            }++            Section("Tags") {+                // Simple comma-separated tag editing+                TextField(+                    "Genre tags (comma-separated)",+                    text: Binding(+                        get: { model.draftTags.joined(separator: ", ") },+                        set: { model.draftTags = $0.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } }+                    )+                )+                .accessibilityIdentifier("work-detail-tags-field")+            }++            if !work.entries.isEmpty {+                Section("Entries (\(work.entries.count))") {+                    ForEach(work.entries, id: \.id) { entry in+                        RecentEntryRow(entry: entry)+                            .accessibilityIdentifier("work-detail-entry")+                            .accessibilityLabel("Assigned entry: \(entry.captureTitle)")+                    }+                }+            }++            if let errorMessage = model.errorMessage {+                Section {+                    Text(errorMessage)+                        .foregroundStyle(.red)+                        .accessibilityIdentifier("work-detail-error")+                }+            }++            Section {+                Button("Save") {+                    Task { await model.save() }+                }+                .disabled(model.state == .submitting)+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("work-detail-save-button")+            }+        }+    }+}
Asterism/Asterism/Views/WorksView.swift Added +126 / -0
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftnew file mode 100644index 0000000..167a467--- /dev/null+++ b/Asterism/Asterism/Views/WorksView.swift@@ -0,0 +1,126 @@+import AsterismCore+import SwiftUI++/// Works tab: shows non-empty works, empty works, and unattached entries group.+struct WorksView: View {+    let snapshot: WorksSnapshot+    let onSelectWork: (UUID) -> Void+    let onSelectEntry: (UUID) -> Void+    let onNewWork: () -> Void++    private var nonEmptyWorks: [WorkSnapshot] {+        snapshot.works.filter { !$0.entries.isEmpty }+    }++    private var emptyWorks: [WorkSnapshot] {+        snapshot.works.filter { $0.entries.isEmpty }+    }++    var body: some View {+        List {+            // Non-empty works section+            if !nonEmptyWorks.isEmpty {+                Section {+                    ForEach(nonEmptyWorks, id: \.id) { work in+                        workButton(work)+                    }+                }+            }++            // Empty works section+            if !emptyWorks.isEmpty {+                Section {+                    ForEach(emptyWorks, id: \.id) { work in+                        workButton(work)+                    }+                }+            }++            // Unattached notes group+            Section {+                if snapshot.unattachedEntries.isEmpty {+                    Text("No unattached notes")+                        .foregroundStyle(.secondary)+                        .accessibilityIdentifier("unattached-empty")+                } else {+                    ForEach(snapshot.unattachedEntries, id: \.id) { entry in+                        Button {+                            onSelectEntry(entry.id)+                        } label: {+                            RecentEntryRow(entry: entry)+                                .contentShape(Rectangle())+                        }+                        .buttonStyle(.plain)+                        .accessibilityIdentifier("unattached-entry-\(entry.id.uuidString)")+                        .accessibilityLabel("Open unattached entry \(entry.captureTitle)")+                    }+                }+            } header: {+                Text("Unattached Notes")+                    .font(AsterismTypography.sectionHeader)+                    .textCase(.uppercase)+                    .accessibilityIdentifier("unattached-section-header")+            }+        }+        .listStyle(.plain)+        .toolbar {+            ToolbarItem(placement: .primaryAction) {+                Button {+                    onNewWork()+                } label: {+                    Label("New Work", systemImage: "plus")+                }+                .accessibilityIdentifier("works-new-work-button")+            }+        }+        .accessibilityIdentifier("works-list")+    }++    private func workButton(_ work: WorkSnapshot) -> some View {+        Button {+            onSelectWork(work.id)+        } label: {+            WorkRow(work: work)+                .contentShape(Rectangle())+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("work-row-\(work.id.uuidString)")+        .accessibilityLabel("Open Work \(work.displayTitle) from \(work.siteHostname)")+    }+}++/// A single row displaying a Work summary.+struct WorkRow: View {+    let work: WorkSnapshot++    var body: some View {+        VStack(alignment: .leading, spacing: 4) {+            Text(work.displayTitle)+                .font(AsterismTypography.serifTitle(15))+                .lineLimit(1)+                .accessibilityIdentifier("work-title")++            HStack(spacing: 6) {+                Text(work.siteHostname)+                    .font(.caption)+                    .foregroundStyle(.secondary)++                if work.type != .other {+                    Text(work.type.rawValue)+                        .font(.caption2.weight(.semibold))+                        .foregroundStyle(AsterismColors.violetDark)+                        .accessibilityIdentifier("work-type-tag")+                }++                Spacer()++                Text("\(work.entries.count)")+                    .font(.caption2.weight(.bold))+                    .foregroundStyle(AsterismColors.cyanDark)+                    .accessibilityIdentifier("work-entry-count")+            }+        }+        .padding(.vertical, 4)+        .frame(minHeight: AsterismLayout.minHitTarget)+    }+}
Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlements Added +5 / -0
diff --git a/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlements b/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlementsnew file mode 100644index 0000000..baf648a--- /dev/null+++ b/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlements@@ -0,0 +1,5 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0"><dict>+<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism.dev</string></array>+</dict></plist>
Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlements Added +5 / -0
diff --git a/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlements b/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlementsnew file mode 100644index 0000000..8fa6706--- /dev/null+++ b/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlements@@ -0,0 +1,5 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0"><dict>+<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism</string></array>+</dict></plist>
Asterism/AsterismShareExtension/CaptureView.swift Added +389 / -0
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftnew file mode 100644index 0000000..26d8025--- /dev/null+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -0,0 +1,389 @@+import AsterismCore+import SwiftUI++/// Capture sheet SwiftUI view: loading, invalid URL, first-site amber state,+/// existing-site neutral state, inline required manual title, focused multiline note,+/// independent up/down rating toggles, save/error/retry/cancel, accessibility labels,+/// 44pt targets, Dynamic Type/scrolling, Reduce Transparency/basic paired appearance.+struct CaptureView: View {+    @ObservedObject var viewModel: ObservableCaptureViewModel+    @FocusState private var noteFieldFocused: Bool+    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency+    @Environment(\.colorScheme) private var colorScheme+    let onSaveCompleted: () -> Void+    let onCancel: () -> Void++    init(observableViewModel: ObservableCaptureViewModel, onSaveCompleted: @escaping () -> Void, onCancel: @escaping () -> Void) {+        self.viewModel = observableViewModel+        self.onSaveCompleted = onSaveCompleted+        self.onCancel = onCancel+    }++    var body: some View {+        NavigationStack {+            ScrollView {+                VStack(spacing: 16) {+                    content+                }+                .padding()+            }+            .navigationTitle("Capture")+            .navigationBarTitleDisplayMode(.inline)+            .toolbar {+                ToolbarItem(placement: .cancellationAction) {+                    Button("Cancel") {+                        viewModel.cancel()+                        onCancel()+                    }+                    .disabled(viewModel.isSaving)+                    .accessibilityLabel("Cancel capture")+                    .frame(minWidth: 44, minHeight: 44)+                }+            }+        }+        .onChange(of: viewModel.isSaved) { _, isSaved in+            if isSaved { onSaveCompleted() }+        }+    }++    @ViewBuilder+    private var content: some View {+        switch viewModel.state {+        case .loading:+            loadingContent+        case .invalidInput(let message):+            invalidInputContent(message: message)+        case .ready(let preparation, _):+            readyContent(preparation: preparation)+        case .saving:+            savingContent+        case .saveFailed(let preparation, _, let message):+            failedContent(preparation: preparation, message: message)+        case .saved:+            savedContent+        case .cancelled:+            EmptyView()+        }+    }++    // MARK: - Surface background (Reduce Transparency aware)++    /// An opaque background for field surfaces that respects Reduce Transparency.+    private var fieldBackground: some ShapeStyle {+        if reduceTransparency {+            return AnyShapeStyle(colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.92))+        } else {+            return AnyShapeStyle(.fill.quaternary)+        }+    }++    // MARK: - Loading++    private var loadingContent: some View {+        VStack(spacing: 12) {+            ProgressView()+                .accessibilityLabel("Loading page information")+            Text("Preparing capture…")+                .font(.subheadline)+                .foregroundStyle(.secondary)+        }+        .frame(maxWidth: .infinity, minHeight: 200)+    }++    // MARK: - Invalid Input++    private func invalidInputContent(message: String) -> some View {+        VStack(spacing: 16) {+            Image(systemName: "exclamationmark.triangle")+                .font(.largeTitle)+                .foregroundStyle(.secondary)+                .accessibilityHidden(true)+            Text(message)+                .font(.body)+                .multilineTextAlignment(.center)+                .foregroundStyle(.primary)+        }+        .frame(maxWidth: .infinity, minHeight: 200)+        .accessibilityElement(children: .combine)+        .accessibilityLabel(message)+    }++    // MARK: - Ready++    @ViewBuilder+    private func readyContent(preparation: CapturePreparation) -> some View {+        metadataSection(preparation: preparation)+        noteSection+        ratingSection+        saveButton+    }++    // MARK: - Saving++    private var savingContent: some View {+        VStack(spacing: 12) {+            ProgressView()+                .accessibilityLabel("Saving capture")+            Text("Saving…")+                .font(.subheadline)+                .foregroundStyle(.secondary)+        }+        .frame(maxWidth: .infinity, minHeight: 200)+    }++    // MARK: - Failed++    @ViewBuilder+    private func failedContent(preparation: CapturePreparation, message: String) -> some View {+        errorBanner(message: message)+        metadataSection(preparation: preparation)+        noteSection+        ratingSection+        retryButton+    }++    // MARK: - Saved++    private var savedContent: some View {+        VStack(spacing: 12) {+            Image(systemName: "checkmark.circle.fill")+                .font(.largeTitle)+                .foregroundStyle(.green)+                .accessibilityHidden(true)+            Text("Saved")+                .font(.headline)+        }+        .frame(maxWidth: .infinity, minHeight: 200)+        .accessibilityLabel("Capture saved successfully")+    }++    // MARK: - Metadata++    @ViewBuilder+    private func metadataSection(preparation: CapturePreparation) -> some View {+        VStack(alignment: .leading, spacing: 8) {+            if preparation.siteStatus == .newSite {+                newSiteBanner+            }++            if preparation.titleSource == .manual && preparation.resolvedTitle == nil {+                manualTitleInput+            } else if let title = preparation.resolvedTitle {+                Text(title)+                    .font(.headline)+                    .accessibilityLabel("Page title")+            }++            if let host = Self.hostname(from: preparation.rawURL) {+                Text(host)+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityLabel("Site: \(host)")+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .padding()+        .background(fieldBackground)+        .clipShape(RoundedRectangle(cornerRadius: 12))+    }++    private var newSiteBanner: some View {+        HStack(spacing: 6) {+            Image(systemName: "star.fill")+                .font(.caption)+                .foregroundStyle(.orange)+                .accessibilityHidden(true)+            Text("New site — title saved, teach later in app")+                .font(.caption)+                .foregroundStyle(.orange)+        }+        .accessibilityElement(children: .combine)+        .accessibilityLabel("New site. Title will be saved. Teach later in app.")+    }++    private var manualTitleInput: some View {+        TextField("Title (required)", text: $viewModel.manualTitle)+            .font(.headline)+            .textFieldStyle(.plain)+            .accessibilityLabel("Page title, required")+            .accessibilityHint("Enter a title for this capture")+            .frame(minHeight: 44)+    }++    // MARK: - Note++    private var noteSection: some View {+        VStack(alignment: .leading, spacing: 4) {+            Text("Note")+                .font(.caption)+                .foregroundStyle(.secondary)+            TextEditor(text: $viewModel.note)+                .focused($noteFieldFocused)+                .font(.body)+                .frame(minHeight: 88)+                .scrollContentBackground(.hidden)+                .padding(8)+                .background(fieldBackground)+                .clipShape(RoundedRectangle(cornerRadius: 12))+                .accessibilityLabel("Note")+                .accessibilityHint("Optional note for this capture")+                .onAppear {+                    // Focus the note field when it appears so keyboard presents immediately+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {+                        noteFieldFocused = true+                    }+                }+        }+    }++    // MARK: - Ratings++    private var ratingSection: some View {+        HStack(spacing: 16) {+            ratingToggle(rating: .up, symbol: "arrowtriangle.up.fill", label: "Rate up")+            ratingToggle(rating: .down, symbol: "arrowtriangle.down.fill", label: "Rate down")+            Spacer()+        }+    }++    private func ratingToggle(rating: Rating, symbol: String, label: String) -> some View {+        let isSelected = viewModel.currentRating == rating+        let accentColor: Color = rating == .up ? .cyan : .purple+        return Button {+            viewModel.toggleRating(rating)+        } label: {+            Image(systemName: symbol)+                .font(.title3)+                .foregroundStyle(isSelected ? accentColor : .secondary)+                .frame(width: 48, height: 44)+                .background(isSelected ? accentColor.opacity(0.15) : Color.clear)+                .clipShape(Capsule())+                .overlay(Capsule().stroke(isSelected ? accentColor.opacity(0.55) : Color.clear, lineWidth: 1))+        }+        .disabled(viewModel.isSaving)+        .accessibilityLabel(label)+        .accessibilityAddTraits(isSelected ? .isSelected : [])+    }++    // MARK: - Actions++    private var saveButton: some View {+        Button {+            viewModel.save()+        } label: {+            Text("Save")+                .font(.headline)+                .frame(maxWidth: .infinity, minHeight: 44)+        }+        .buttonStyle(.borderedProminent)+        .disabled(!viewModel.canSave || viewModel.isSaving)+        .accessibilityLabel("Save capture")+    }++    private var retryButton: some View {+        Button {+            viewModel.save()+        } label: {+            Text("Try Again")+                .font(.headline)+                .frame(maxWidth: .infinity, minHeight: 44)+        }+        .buttonStyle(.borderedProminent)+        .disabled(!viewModel.canSave || viewModel.isSaving)+        .accessibilityLabel("Try saving again")+    }++    private func errorBanner(message: String) -> some View {+        HStack(spacing: 8) {+            Image(systemName: "exclamationmark.triangle.fill")+                .foregroundStyle(.red)+                .accessibilityHidden(true)+            Text(message)+                .font(.subheadline)+                .foregroundStyle(.primary)+        }+        .padding()+        .frame(maxWidth: .infinity, alignment: .leading)+        .background(reduceTransparency+            ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))+            : AnyShapeStyle(Color.red.opacity(0.1)))+        .clipShape(RoundedRectangle(cornerRadius: 12))+        .accessibilityElement(children: .combine)+        .accessibilityLabel("Error: \(message)")+    }++    // MARK: - Helpers++    private static func hostname(from urlString: String) -> String? {+        URLComponents(string: urlString)?.host+    }+}++// MARK: - Observable wrapper for CaptureViewModel (bridges @MainActor to SwiftUI)++@MainActor+final class ObservableCaptureViewModel: ObservableObject {+    private let viewModel: CaptureViewModel+    private var coordinator: CaptureCoordinator?+    private var saveTask: Task<Void, Never>?++    @Published var state: CaptureViewState+    @Published var manualTitle: String = "" {+        didSet { viewModel.setManualTitle(manualTitle) ; refreshState() }+    }+    @Published var note: String = "" {+        didSet { viewModel.setNote(note) ; refreshState() }+    }++    var canSave: Bool { viewModel.canSave }+    var isSaved: Bool {+        if case .saved = state { return true }+        return false+    }+    var isSaving: Bool {+        if case .saving = state { return true }+        return false+    }+    var currentRating: Rating? { viewModel.currentDraft?.rating }++    init(viewModel: CaptureViewModel) {+        self.viewModel = viewModel+        self.state = viewModel.state+    }++    func cancel() {+        guard saveTask == nil else { return }+        viewModel.cancel()+        refreshState()+    }++    func toggleRating(_ rating: Rating) {+        viewModel.setRating(rating)+        refreshState()+    }++    func save() {+        guard saveTask == nil,+              let coordinator,+              viewModel.canSave,+              let draft = viewModel.currentDraft else { return }++        // Publish the in-flight state synchronously so controls and cancellation+        // are disabled before the asynchronous repository save begins.+        state = .saving(draft: draft)+        saveTask = Task { [weak self] in+            guard let self else { return }+            await viewModel.save(coordinator: coordinator)+            refreshState()+            saveTask = nil+        }+    }++    func setCoordinator(_ coordinator: CaptureCoordinator) {+        self.coordinator = coordinator+    }++    func refreshState() {+        state = viewModel.state+    }+}
Asterism/AsterismShareExtension/Info.plist Added +41 / -0
diff --git a/Asterism/AsterismShareExtension/Info.plist b/Asterism/AsterismShareExtension/Info.plistnew file mode 100644index 0000000..9d6b76f--- /dev/null+++ b/Asterism/AsterismShareExtension/Info.plist@@ -0,0 +1,41 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">+<plist version="1.0">+<dict>+    <key>CFBundleDisplayName</key>+    <string>Asterism</string>+    <key>CFBundleExecutable</key>+    <string>$(EXECUTABLE_NAME)</string>+    <key>CFBundleIdentifier</key>+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>+    <key>CFBundleInfoDictionaryVersion</key>+    <string>6.0</string>+    <key>CFBundleName</key>+    <string>$(PRODUCT_NAME)</string>+    <key>CFBundlePackageType</key>+    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>+    <key>CFBundleShortVersionString</key>+    <string>$(MARKETING_VERSION)</string>+    <key>CFBundleVersion</key>+    <string>$(CURRENT_PROJECT_VERSION)</string>+    <key>NSExtension</key>+    <dict>+        <key>NSExtensionAttributes</key>+        <dict>+            <key>NSExtensionActivationRule</key>+            <dict>+                <key>NSExtensionActivationSupportsWebPageWithMaxCount</key>+                <integer>1</integer>+                <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>+                <integer>1</integer>+            </dict>+            <key>NSExtensionJavaScriptPreprocessingFile</key>+            <string>Preprocessing</string>+        </dict>+        <key>NSExtensionPointIdentifier</key>+        <string>com.apple.share-services</string>+        <key>NSExtensionPrincipalClass</key>+        <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>+    </dict>+</dict>+</plist>
Asterism/AsterismShareExtension/Preprocessing.js Added +17 / -0
diff --git a/Asterism/AsterismShareExtension/Preprocessing.js b/Asterism/AsterismShareExtension/Preprocessing.jsnew file mode 100644index 0000000..208ee40--- /dev/null+++ b/Asterism/AsterismShareExtension/Preprocessing.js@@ -0,0 +1,17 @@+var ExtensionPreprocessingJS = new function () {+    this.run = function (arguments) {+        var canonicalCandidates = [];+        var links = document.getElementsByTagName("link");+        for (var index = 0; index < links.length; index += 1) {+            var relTokens = (links[index].getAttribute("rel") || "").split(/\s+/);+            if (relTokens.some(function (token) { return token.toLowerCase() === "canonical"; })) {+                canonicalCandidates.push(links[index].getAttribute("href") || "");+            }+        }+        arguments.completionFunction({+            title: document.title,+            locationHref: location.href,+            canonicalCandidates: canonicalCandidates+        });+    };+};
Asterism/AsterismShareExtension/ShareInputAdapter.swift Added +79 / -0
diff --git a/Asterism/AsterismShareExtension/ShareInputAdapter.swift b/Asterism/AsterismShareExtension/ShareInputAdapter.swiftnew file mode 100644index 0000000..698b38c--- /dev/null+++ b/Asterism/AsterismShareExtension/ShareInputAdapter.swift@@ -0,0 +1,79 @@+import AsterismCore+import Foundation+import UIKit+import UniformTypeIdentifiers++// MARK: - Production NSItemProvider conformance to ItemProviderLoading++extension NSItemProvider: @retroactive ItemProviderLoading {+    public var hasURL: Bool {+        hasItemConformingToTypeIdentifier(UTType.url.identifier)+    }++    public var hasPropertyList: Bool {+        hasItemConformingToTypeIdentifier(UTType.propertyList.identifier)+    }++    public func loadURL(completion: @escaping @Sendable (URL?, (any Error)?) -> Void) -> Progress {+        loadObject(ofClass: NSURL.self) { object, error in+            completion(object as? URL, error)+        }+    }++    public func loadPropertyList(completion: @escaping @Sendable ([String: Any]?, (any Error)?) -> Void) -> Progress {+        let progress = Progress(totalUnitCount: 1)+        self.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { item, error in+            progress.completedUnitCount = 1+            completion(item as? [String: Any], error)+        }+        return progress+    }+}++// MARK: - NSExtensionItem conformance to ExtensionItemProviding++struct NSExtensionItemWrapper: ExtensionItemProviding, @unchecked Sendable {+    let item: NSExtensionItem++    var attributedTitleString: String? {+        item.attributedTitle?.string+    }++    var providers: [any ItemProviderLoading] {+        (item.attachments ?? []) as [NSItemProvider]+    }+}++// MARK: - ShareInputAdapter (thin @MainActor wrapper)++/// @MainActor adapter that maps NSExtensionItem → ExtensionItemProviding+/// and delegates to the testable SharePayloadExtractor in AsterismCore.+@MainActor+final class ShareInputAdapter {++    enum AdapterError: Error {+        case noUsableURL+        case cancelled+    }++    private let extractor = SharePayloadExtractor()++    /// Cancel all outstanding provider loads.+    func cancelOutstanding() {+        extractor.cancelOutstanding()+    }++    /// Extract a SharePayload from the extension context's input items.+    func extractPayload(from extensionItems: [NSExtensionItem]) async throws -> SharePayload {+        let wrappers = extensionItems.map { NSExtensionItemWrapper(item: $0) }+        do {+            return try await extractor.extract(from: wrappers)+        } catch is CancellationError {+            throw AdapterError.cancelled+        } catch SharePayloadExtractionError.noUsableURL {+            throw AdapterError.noUsableURL+        } catch SharePayloadExtractionError.cancelled {+            throw AdapterError.cancelled+        }+    }+}
Asterism/AsterismShareExtension/ShareViewController.swift Added +134 / -0
diff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftnew file mode 100644index 0000000..1594be0--- /dev/null+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -0,0 +1,134 @@+import AsterismCore+import SwiftUI+import UIKit++/// Share extension entry point. Bootstraps the repository from the immutable+/// matching configuration, loads the share payload via ShareInputAdapter,+/// coordinates capture with BoundedPageTitleFetcher, embeds CaptureView,+/// and manages retry/cancel/save flow with exactly-once completeRequest.+final class ShareViewController: UIViewController {++    private var hostingController: UIHostingController<CaptureView>?+    private var observableViewModel: ObservableCaptureViewModel?+    private var hasCompleted = false+    /// Retained loading task for cancellation when extension is dismissed.+    private var loadingTask: Task<Void, Never>?++    override func viewDidLoad() {+        super.viewDidLoad()+        view.backgroundColor = .systemBackground+        accessibilityViewIsModal = true++        let viewModel = CaptureViewModel()+        let observable = ObservableCaptureViewModel(viewModel: viewModel)+        observableViewModel = observable++        let captureView = CaptureView(+            observableViewModel: observable,+            onSaveCompleted: { [weak self] in+                self?.completeExtension()+            },+            onCancel: { [weak self] in+                self?.cancelExtension()+            }+        )++        let hosting = UIHostingController(rootView: captureView)+        addChild(hosting)+        hosting.view.translatesAutoresizingMaskIntoConstraints = false+        view.addSubview(hosting.view)+        NSLayoutConstraint.activate([+            hosting.view.topAnchor.constraint(equalTo: view.topAnchor),+            hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),+            hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),+            hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),+        ])+        hosting.didMove(toParent: self)+        hostingController = hosting++        loadingTask = Task { @MainActor in+            await self.loadAndPrepare(viewModel: viewModel)+        }+    }++    @MainActor+    private func loadAndPrepare(viewModel: CaptureViewModel) async {+        // 1. Bootstrap repository from fail-closed configuration resolution+        let configuration: LibraryConfiguration+        do {+            configuration = try LibraryConfiguration.production(environment: .current)+        } catch {+            viewModel.setInvalidInput(message: "Library unavailable. The app group could not be resolved.")+            observableViewModel?.refreshState()+            return+        }++        let repository: LibraryRepository+        do {+            repository = try await LibraryRepository.open(configuration)+        } catch {+            viewModel.setInvalidInput(message: "Library unavailable. Please try again later.")+            observableViewModel?.refreshState()+            return+        }++        // 2. Extract payload from extension items+        let adapter = ShareInputAdapter()+        guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {+            viewModel.setInvalidInput(message: "No content was shared.")+            observableViewModel?.refreshState()+            return+        }++        let payload: SharePayload+        do {+            payload = try await adapter.extractPayload(from: extensionItems)+        } catch ShareInputAdapter.AdapterError.noUsableURL {+            viewModel.setInvalidInput(message: "A page URL is required.")+            observableViewModel?.refreshState()+            return+        } catch ShareInputAdapter.AdapterError.cancelled {+            cancelExtension()+            return+        } catch {+            viewModel.setInvalidInput(message: "Unable to process shared content.")+            observableViewModel?.refreshState()+            return+        }++        // 3. Create coordinator with bounded fetcher for non-Safari payloads+        let fetcher = BoundedPageTitleFetcher()+        let coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)++        // 4. Store coordinator for save operations+        observableViewModel?.setCoordinator(coordinator)++        // 5. Load payload through view model (handles prepare internally)+        await viewModel.load(payload: payload, coordinator: coordinator)+        observableViewModel?.refreshState()+    }++    // MARK: - Extension lifecycle++    /// Complete the extension exactly once, only after persistence succeeds.+    private func completeExtension() {+        guard !hasCompleted else { return }+        hasCompleted = true+        loadingTask?.cancel()+        loadingTask = nil+        extensionContext?.completeRequest(returningItems: nil)+    }++    /// Cancel the extension — writes nothing. Blocked during .saving state.+    private func cancelExtension() {+        guard !hasCompleted else { return }+        // Do not allow cancel while a save is in-flight — the write may succeed+        if let vm = observableViewModel, case .saving = vm.state {+            return+        }+        hasCompleted = true+        loadingTask?.cancel()+        loadingTask = nil+        extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))+    }+}
Asterism/AsterismTests/AppLibraryModelTests.swift Added +169 / -0
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftnew file mode 100644index 0000000..59aa903--- /dev/null+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -0,0 +1,169 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for AppLibraryModel: loading, ready, unavailable, retry, activation refresh,+/// complete snapshot replacement, and configuration isolation.+@Suite("AppLibraryModel")+struct AppLibraryModelTests {++    @Test("Starts in loading state")+    @MainActor func initialState() {+        let config = LibraryConfiguration(+            rootDirectory: URL(filePath: "/tmp/test-\(UUID())"),+            environment: .development+        )+        let model = AppLibraryModel(configuration: config)+        #expect(model.state == .loading)+        #expect(model.recentGroups.isEmpty)+        #expect(model.worksSnapshot.works.isEmpty)+    }++    @Test("Transitions to unavailable when bootstrap fails")+    @MainActor func bootstrapFailure() async {+        // Use a nonexistent root that will fail (no App Group container)+        let config = LibraryConfiguration(+            rootDirectory: URL(filePath: "/nonexistent-\(UUID())/library"),+            environment: .development+        )+        let model = AppLibraryModel(configuration: config)+        await model.bootstrap()+        guard case .unavailable = model.state else {+            Issue.record("Expected unavailable state, got \(model.state)")+            return+        }+    }++    @Test("Transitions to ready with valid temporary directory")+    @MainActor func bootstrapSuccess() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-test-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let model = AppLibraryModel(configuration: config)+        await model.bootstrap()+        #expect(model.state == .ready)+        try? FileManager.default.removeItem(at: tmp)+    }++    @Test("Retry after unavailable re-attempts bootstrap")+    @MainActor func retryTransition() async {+        let config = LibraryConfiguration(+            rootDirectory: URL(filePath: "/nonexistent-\(UUID())/library"),+            environment: .development+        )+        let model = AppLibraryModel(configuration: config)+        await model.bootstrap()+        guard case .unavailable = model.state else {+            Issue.record("Expected unavailable"); return+        }+        // Retry should re-attempt (and still fail with the bad path)+        await model.retry()+        guard case .unavailable = model.state else {+            Issue.record("Expected unavailable after retry"); return+        }+    }++    @Test("Activation refreshes snapshots when ready")+    @MainActor func activationRefresh() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-test-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let model = AppLibraryModel(configuration: config)+        await model.bootstrap()+        #expect(model.state == .ready)++        // Activation should not crash and should refresh (empty library)+        await model.handleActivation()+        #expect(model.recentGroups.isEmpty)+        try? FileManager.default.removeItem(at: tmp)+    }++    @Test("Configuration isolation: different environments use different paths")+    @MainActor func configurationIsolation() async {+        let baseTmp = FileManager.default.temporaryDirectory.appending(path: "asterism-iso-\(UUID())")+        let devConfig = LibraryConfiguration(+            rootDirectory: baseTmp.appending(path: "dev"),+            environment: .development+        )+        let personalConfig = LibraryConfiguration(+            rootDirectory: baseTmp.appending(path: "personal"),+            environment: .personal+        )+        #expect(devConfig.storeURL != personalConfig.storeURL)+        #expect(devConfig.rootDirectory != personalConfig.rootDirectory)+        try? FileManager.default.removeItem(at: baseTmp)+    }++    @Test("Snapshots are completely replaced after mutation")+    @MainActor func snapshotReplacement() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-test-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let model = AppLibraryModel(configuration: config)+        await model.bootstrap()+        #expect(model.state == .ready)++        // Initially empty+        #expect(model.recentGroups.isEmpty)+        #expect(model.worksSnapshot.works.isEmpty)+        #expect(model.worksSnapshot.unattachedEntries.isEmpty)++        // After refresh, still empty (no data added through this test)+        await model.refreshAll()+        #expect(model.recentGroups.isEmpty)+        try? FileManager.default.removeItem(at: tmp)+    }++    // MARK: - Fail-closed locator tests (Requirement 4.4/4.5)++    @Test("Locator failure transitions to unavailable without opening any repository")+    @MainActor func locatorFailureRendersUnavailable() async {+        let locator = FailingLocator()+        let model = AppLibraryModel(environment: .development, locator: locator)+        await model.bootstrap()+        guard case .unavailable(let message) = model.state else {+            Issue.record("Expected unavailable state, got \(model.state)")+            return+        }+        // The model transitions to unavailable — message content varies by runtime+        #expect(!message.isEmpty, "Error message should not be empty")+    }++    @Test("Locator failure does not create files at Documents or /tmp fallback")+    @MainActor func locatorFailureCreatesNoFallbackFiles() async {+        let locator = FailingLocator()+        let model = AppLibraryModel(environment: .development, locator: locator)++        // Record state of common fallback locations+        let tmpFallback = URL(filePath: "/tmp/asterism-fallback")+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!+            .appending(path: "Asterism")+        let tmpExistedBefore = FileManager.default.fileExists(atPath: tmpFallback.path)+        let docsExistedBefore = FileManager.default.fileExists(atPath: docs.path)++        await model.bootstrap()++        // No fallback directory created+        #expect(FileManager.default.fileExists(atPath: tmpFallback.path) == tmpExistedBefore)+        #expect(FileManager.default.fileExists(atPath: docs.path) == docsExistedBefore)+    }++    @Test("Environment-based model with working locator succeeds")+    @MainActor func environmentBasedModelWithWorkingLocator() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-locator-\(UUID())")+        let locator = StubLocator(url: tmp)+        let model = AppLibraryModel(environment: .development, locator: locator)+        await model.bootstrap()+        #expect(model.state == .ready)+        try? FileManager.default.removeItem(at: tmp)+    }+}++// MARK: - Test helpers++private struct FailingLocator: SharedContainerLocating {+    func containerURL(forAppGroup identifier: String) -> URL? { nil }+}++private struct StubLocator: SharedContainerLocating {+    let url: URL+    func containerURL(forAppGroup identifier: String) -> URL? { url }+}
Asterism/AsterismTests/AsterismTests.swift Modified +4 / -6
diff --git a/Asterism/AsterismTests/AsterismTests.swift b/Asterism/AsterismTests/AsterismTests.swiftindex e32b126..56493ed 100644--- a/Asterism/AsterismTests/AsterismTests.swift+++ b/Asterism/AsterismTests/AsterismTests.swift@@ -5,14 +5,12 @@ //  Created by Arjen Schwarz on 16/7/2026. // +import AsterismCore import Testing  struct AsterismTests {--    @Test func example() async throws {-        // Write your test here and use APIs like `#expect(...)` to check expected conditions.-        // Swift Testing Documentation-        // https://developer.apple.com/documentation/testing+    @Test("App links the core module")+    func linksCoreModule() {+        #expect(LibraryEnvironment.allCases.count == 2)     }- }
Asterism/AsterismTests/CrossViewRefreshTests.swift Added +127 / -0
diff --git a/Asterism/AsterismTests/CrossViewRefreshTests.swift b/Asterism/AsterismTests/CrossViewRefreshTests.swiftnew file mode 100644index 0000000..dea3d08--- /dev/null+++ b/Asterism/AsterismTests/CrossViewRefreshTests.swift@@ -0,0 +1,127 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for cross-view refresh: mutations in detail models trigger+/// AppLibraryModel snapshot reload.+@Suite("CrossViewRefresh")+struct CrossViewRefreshTests {++    @Test("Entry detail refresh triggers AppLibraryModel refreshAll")+    @MainActor func entryEditRefreshesParent() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-xv-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let appModel = AppLibraryModel(configuration: config)+        await appModel.bootstrap()+        #expect(appModel.state == .ready)++        // Capture initial Recent snapshot+        let initialRecent = appModel.recentGroups+        // After a mutation (even though the library is empty), refreshAll should run+        await appModel.refreshAll()+        // With an empty library, both should be empty+        #expect(appModel.recentGroups == initialRecent)+        try? FileManager.default.removeItem(at: tmp)+    }++    @Test("Activation after external mutation shows updated snapshots")+    @MainActor func activationShowsUpdatedData() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-act-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let appModel = AppLibraryModel(configuration: config)+        await appModel.bootstrap()+        #expect(appModel.state == .ready)+        #expect(appModel.recentGroups.isEmpty)++        // Simulate: extension captures an entry (through direct repo access)+        let repo = try! await LibraryRepository.open(config)+        let draft = CaptureDraft(+            captureTitle: "Ext Capture",+            captureTitleSource: .host,+            rawURLString: "https://example.com/page"+        )+        _ = try! await repo.capture(draft)++        // Before activation, appModel still shows old snapshot+        #expect(appModel.recentGroups.isEmpty)++        // After activation, should refresh and show the new entry+        await appModel.handleActivation()+        #expect(!appModel.recentGroups.isEmpty)+        #expect(appModel.recentGroups.first?.entries.first?.captureTitle == "Ext Capture")+        try? FileManager.default.removeItem(at: tmp)+    }++    @Test("Recent ordering: newest lastSharedAt first, UUID tiebreak")+    @MainActor func recentOrdering() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-ord-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let appModel = AppLibraryModel(configuration: config)+        await appModel.bootstrap()++        let repo = try! await LibraryRepository.open(config)+        // Create two entries; first capture is older+        let draft1 = CaptureDraft(+            captureTitle: "First",+            captureTitleSource: .host,+            rawURLString: "https://example.com/a"+        )+        let draft2 = CaptureDraft(+            captureTitle: "Second",+            captureTitleSource: .host,+            rawURLString: "https://example.com/b"+        )+        _ = try! await repo.capture(draft1)+        // Tiny delay to ensure different timestamp+        try! await Task.sleep(for: .milliseconds(5))+        _ = try! await repo.capture(draft2)++        await appModel.handleActivation()+        let allEntries = appModel.recentGroups.flatMap(\.entries)+        #expect(allEntries.count == 2)+        // Second (newer) should appear first+        #expect(allEntries[0].captureTitle == "Second")+        #expect(allEntries[1].captureTitle == "First")+        try? FileManager.default.removeItem(at: tmp)+    }++    @Test("Works ordering: non-empty by newest entry, empty by modifiedAt")+    @MainActor func worksOrdering() async {+        let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-wk-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+        let appModel = AppLibraryModel(configuration: config)+        await appModel.bootstrap()++        let repo = try! await LibraryRepository.open(config)+        // Create an entry and assign it to a new work+        let draft = CaptureDraft(+            captureTitle: "Assigned",+            captureTitleSource: .host,+            rawURLString: "https://example.com/c"+        )+        let entry = try! await repo.capture(draft)++        // Create an empty Work+        _ = try! await repo.createWork(+            NewWorkDraft(displayTitle: "Empty Work", hostname: "example.com")+        )++        // Move entry to a new work+        try! await repo.moveEntry(entry.id, to: .newWork(displayTitle: "Non-Empty Work"))++        await appModel.handleActivation()+        let works = appModel.worksSnapshot.works+        // Non-empty work should appear before empty work+        #expect(works.count == 2)+        let nonEmpty = works.first { !$0.entries.isEmpty }+        let empty = works.first { $0.entries.isEmpty }+        #expect(nonEmpty != nil)+        #expect(empty != nil)+        if let ni = works.firstIndex(where: { !$0.entries.isEmpty }),+           let ei = works.firstIndex(where: { $0.entries.isEmpty }) {+            #expect(ni < ei)+        }+        try? FileManager.default.removeItem(at: tmp)+    }+}
Asterism/AsterismTests/EntryDetailModelTests.swift Added +114 / -0
diff --git a/Asterism/AsterismTests/EntryDetailModelTests.swift b/Asterism/AsterismTests/EntryDetailModelTests.swiftnew file mode 100644index 0000000..776479a--- /dev/null+++ b/Asterism/AsterismTests/EntryDetailModelTests.swift@@ -0,0 +1,114 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for EntryDetailModel: drafts, update, delete, submission suppression,+/// failure without optimistic mutation.+@Suite("EntryDetailModel")+struct EntryDetailModelTests {++    @MainActor private func makeSUT(+        entry: EntrySnapshot? = nil,+        updateResult: Result<Void, Error> = .success(()),+        deleteResult: Result<Void, Error> = .success(())+    ) -> (EntryDetailModel, MockLibraryProvider, CallbackTracker) {+        let mock = MockLibraryProvider()+        let entrySnapshot = entry ?? TestFixtures.makeEntry(note: "original note", rating: .up)+        mock.entryResult = .success(entrySnapshot)+        mock.updateEntryResult = updateResult+        mock.deleteEntryResult = deleteResult++        let tracker = CallbackTracker()+        let model = EntryDetailModel(+            entryID: entrySnapshot.id,+            library: mock,+            onMutation: { tracker.mutationCount += 1 }+        )+        return (model, mock, tracker)+    }++    @Test("Load populates entry and drafts")+    @MainActor func loadPopulatesDrafts() async {+        let entry = TestFixtures.makeEntry(note: "my note", rating: .down)+        let (model, _, _) = makeSUT(entry: entry)+        await model.load()+        #expect(model.state == .ready)+        #expect(model.entry == entry)+        #expect(model.draftNote == "my note")+        #expect(model.draftRating == .down)+    }++    @Test("Update commits note and rating changes")+    @MainActor func updateCommits() async {+        let (model, mock, tracker) = makeSUT()+        await model.load()+        model.draftNote = "updated note"+        model.draftRating = .down+        await model.update()+        #expect(mock.updateEntryCallCount == 1)+        #expect(mock.lastUpdateNote == "updated note")+        #expect(mock.lastUpdateRating == .down)+        #expect(tracker.mutationCount == 1)+    }++    @Test("Update failure does not optimistically mutate entry snapshot")+    @MainActor func updateFailureNoOptimistic() async {+        let entry = TestFixtures.makeEntry(note: "original", rating: .up)+        let (model, _, _) = makeSUT(+            entry: entry,+            updateResult: .failure(MockLibraryProvider.MockError.simulatedFailure("save failed"))+        )+        await model.load()+        model.draftNote = "modified"+        model.draftRating = .down+        await model.update()+        // Drafts restored to snapshot values+        #expect(model.draftNote == "original")+        #expect(model.draftRating == .up)+        #expect(model.errorMessage != nil)+        guard case .error = model.state else {+            Issue.record("Expected error state"); return+        }+    }++    @Test("Delete succeeds and nulls entry")+    @MainActor func deleteSuccess() async {+        let (model, mock, tracker) = makeSUT()+        await model.load()+        await model.delete()+        #expect(mock.deleteEntryCallCount == 1)+        #expect(model.entry == nil)+        #expect(tracker.mutationCount == 1)+    }++    @Test("Delete failure retains entry")+    @MainActor func deleteFailure() async {+        let entry = TestFixtures.makeEntry()+        let (model, _, _) = makeSUT(+            entry: entry,+            deleteResult: .failure(MockLibraryProvider.MockError.simulatedFailure("delete failed"))+        )+        await model.load()+        await model.delete()+        #expect(model.entry == entry)+        #expect(model.errorMessage != nil)+    }++    @Test("Duplicate submission is suppressed")+    @MainActor func duplicateSubmissionSuppressed() async {+        let (model, mock, _) = makeSUT()+        await model.load()+        // Simulate rapid double-tap by calling update back-to-back+        async let first: () = model.update()+        async let second: () = model.update()+        _ = await (first, second)+        // Only one should have gone through+        #expect(mock.updateEntryCallCount == 1)+    }+}++/// Helper to track callback invocations.+final class CallbackTracker: @unchecked Sendable {+    var mutationCount = 0+}
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Added +108 / -0
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftnew file mode 100644index 0000000..c19adf4--- /dev/null+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -0,0 +1,108 @@+import AsterismCore+import Foundation++/// Test double for LibraryProviding that records calls and returns preset results.+final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {+    // MARK: - Call tracking++    var recentEntriesCallCount = 0+    var worksCallCount = 0+    var entryCallCount = 0+    var workCallCount = 0+    var updateEntryCallCount = 0+    var deleteEntryCallCount = 0+    var createWorkCallCount = 0+    var updateWorkCallCount = 0+    var moveEntryCallCount = 0+    var workDestinationsCallCount = 0++    // MARK: - Preset results++    var recentEntriesResult: Result<[DatedEntryGroup], Error> = .success([])+    var worksResult: Result<WorksSnapshot, Error> = .success(WorksSnapshot(works: [], unattachedEntries: []))+    var entryResult: Result<EntrySnapshot, Error> = .failure(MockError.notConfigured)+    var workResult: Result<WorkSnapshot, Error> = .failure(MockError.notConfigured)+    var updateEntryResult: Result<Void, Error> = .success(())+    var deleteEntryResult: Result<Void, Error> = .success(())+    var createWorkResult: Result<WorkSnapshot, Error> = .failure(MockError.notConfigured)+    var updateWorkResult: Result<Void, Error> = .success(())+    var moveEntryResult: Result<Void, Error> = .success(())+    var workDestinationsResult: Result<[WorkSnapshot], Error> = .success([])++    // MARK: - Captured arguments++    var lastUpdateEntryID: UUID?+    var lastUpdateNote: String?+    var lastUpdateRating: Rating?+    var lastDeleteEntryID: UUID?+    var lastMoveEntryID: UUID?+    var lastMoveDestination: WorkDestination?++    enum MockError: Error, LocalizedError {+        case notConfigured+        case simulatedFailure(String)++        var errorDescription: String? {+            switch self {+            case .notConfigured: "Mock not configured"+            case .simulatedFailure(let msg): msg+            }+        }+    }++    func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup] {+        recentEntriesCallCount += 1+        return try recentEntriesResult.get()+    }++    func works() async throws -> WorksSnapshot {+        worksCallCount += 1+        return try worksResult.get()+    }++    func entry(id: UUID) async throws -> EntrySnapshot {+        entryCallCount += 1+        return try entryResult.get()+    }++    func work(id: UUID) async throws -> WorkSnapshot {+        workCallCount += 1+        return try workResult.get()+    }++    func updateEntry(id: UUID, note: String, rating: Rating?) async throws {+        updateEntryCallCount += 1+        lastUpdateEntryID = id+        lastUpdateNote = note+        lastUpdateRating = rating+        try updateEntryResult.get()+    }++    func deleteEntry(id: UUID) async throws {+        deleteEntryCallCount += 1+        lastDeleteEntryID = id+        try deleteEntryResult.get()+    }++    func createWork(_ draft: NewWorkDraft) async throws -> WorkSnapshot {+        createWorkCallCount += 1+        return try createWorkResult.get()+    }++    func updateWork(id: UUID, draft: WorkMetadataDraft) async throws {+        updateWorkCallCount += 1+        try updateWorkResult.get()+    }++    func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws {+        moveEntryCallCount += 1+        lastMoveEntryID = entryID+        lastMoveDestination = destination+        try moveEntryResult.get()+    }++    func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot] {+        workDestinationsCallCount += 1+        return try workDestinationsResult.get()+    }+}
Asterism/AsterismTests/Helpers/TestFixtures.swift Added +69 / -0
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftnew file mode 100644index 0000000..205ea9f--- /dev/null+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -0,0 +1,69 @@+import AsterismCore+import Foundation++/// Factory helpers for creating test snapshot fixtures.+enum TestFixtures {+    static let fixedDate = Date(timeIntervalSince1970: 1_750_000_000)+    static let laterDate = Date(timeIntervalSince1970: 1_750_000_060)+    static let earlierDate = Date(timeIntervalSince1970: 1_750_000_000 - 60)++    static func makeEntry(+        id: UUID = UUID(),+        captureTitle: String = "Test Entry",+        hostname: String = "example.com",+        note: String = "",+        rating: Rating? = nil,+        lastSharedAt: Date = fixedDate,+        modifiedAt: Date = fixedDate,+        workID: UUID? = nil+    ) -> EntrySnapshot {+        EntrySnapshot(+            id: id,+            captureTitle: captureTitle,+            captureTitleSource: .host,+            rawURLString: "https://\(hostname)/page",+            canonicalURLString: nil,+            hostname: hostname,+            entryIdentityKey: "https://\(hostname)/page",+            identityKeyVersion: 1,+            chapterTitle: nil,+            chapterTitleProvenance: try! FieldProvenance(kind: .none),+            note: note,+            rating: rating,+            firstCapturedAt: fixedDate,+            lastSharedAt: lastSharedAt,+            modifiedAt: modifiedAt,+            workID: workID,+            workAssignmentProvenance: try! FieldProvenance(kind: .none),+            intentionallyUnattached: false+        )+    }++    static func makeWork(+        id: UUID = UUID(),+        displayTitle: String = "Test Work",+        hostname: String = "example.com",+        type: WorkType = .other,+        genreTags: [String] = [],+        genericNotes: String = "",+        entries: [EntrySnapshot] = [],+        createdAt: Date = fixedDate,+        modifiedAt: Date = fixedDate+    ) -> WorkSnapshot {+        WorkSnapshot(+            id: id,+            displayTitle: displayTitle,+            lastParsedTitle: nil,+            siteHostname: hostname,+            urlIdentity: nil,+            workURLString: nil,+            genericNotes: genericNotes,+            type: type,+            genreTags: genreTags,+            titleProvenance: .manual,+            createdAt: createdAt,+            modifiedAt: modifiedAt,+            entries: entries+        )+    }+}
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Added +186 / -0
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftnew file mode 100644index 0000000..85b526b--- /dev/null+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -0,0 +1,186 @@+import Foundation+import Testing+@testable import Asterism+import AsterismCore++@Suite("Cross-target integration safety net", .serialized)+struct IntegrationSafetyNetTests {+    @Test("Extension capture becomes visible after app activation without leaking configurations")+    @MainActor+    func extensionCaptureRefreshesOnlyMatchingApp() async throws {+        let fixture = try IntegrationFixture()+        defer { fixture.cleanup() }++        let developmentApp = AppLibraryModel(configuration: fixture.developmentConfiguration)+        let personalApp = AppLibraryModel(configuration: fixture.personalConfiguration)+        await developmentApp.bootstrap()+        await personalApp.bootstrap()+        #expect(developmentApp.state == .ready)+        #expect(personalApp.state == .ready)++        let extensionRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+        let captured = try await extensionRepository.capture(+            CaptureDraft(+                captureTitle: "Cross-process chapter",+                captureTitleSource: .host,+                rawURLString: "https://example.test/chapters/1?utm_source=share",+                canonicalURLString: "https://example.test/chapters/1",+                note: "Visible after activation",+                rating: .up+            )+        )++        #expect(developmentApp.recentGroups.isEmpty)+        await developmentApp.handleActivation()+        await personalApp.handleActivation()++        #expect(developmentApp.recentGroups.flatMap(\.entries).map(\.id) == [captured.id])+        #expect(personalApp.recentGroups.isEmpty)+        #expect(personalApp.worksSnapshot.unattachedEntries.isEmpty)+    }++    @Test("A fresh extension repository observes app assignment changes")+    func freshExtensionRepositoryObservesAssignment() async throws {+        let fixture = try IntegrationFixture()+        defer { fixture.cleanup() }++        let appRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+        let entry = try await appRepository.capture(+            CaptureDraft(+                captureTitle: "Assignment chapter",+                captureTitleSource: .manual,+                rawURLString: "https://serial.test/chapter/7"+            )+        )+        let work = try await appRepository.createWork(+            NewWorkDraft(displayTitle: "Assignment Work", hostname: "serial.test")+        )+        try await appRepository.moveEntry(entry.id, to: .existing(work.id))++        let freshExtensionRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+        let observed = try await freshExtensionRepository.entry(id: entry.id)+        let destinations = try await freshExtensionRepository.workDestinations(for: entry.id)++        #expect(observed.workID == work.id)+        #expect(observed.workAssignmentProvenance.kind == .manual)+        #expect(destinations.map(\.id) == [work.id])+    }++    @Test("Restart fails closed when an initialized store disappears")+    func restartDoesNotReplaceMissingLibrary() async throws {+        let fixture = try IntegrationFixture()+        defer { fixture.cleanup() }+        let configuration = fixture.developmentConfiguration++        try await initializeLibrary(at: configuration)+        #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+        #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))++        try FileManager.default.removeItem(at: configuration.storeURL)++        do {+            _ = try await LibraryRepository.open(configuration)+            Issue.record("Expected an initialized library with a missing store to fail closed")+        } catch let error as LibraryRepositoryError {+            guard case .libraryUnavailable = error else {+                Issue.record("Expected libraryUnavailable, got \(error)")+                return+            }+        }++        #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+        #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+    }++    @Test("Validated backup preserves complete real-record values and relationships")+    func completeRealRecordBackup() async throws {+        let fixture = try IntegrationFixture()+        defer { fixture.cleanup() }++        let repository = try await LibraryRepository.open(fixture.developmentConfiguration)+        let entry = try await repository.capture(+            CaptureDraft(+                captureTitle: "Backup chapter ✦",+                captureTitleSource: .safariDocument,+                rawURLString: "https://backup.test/read/3?utm_source=ignored&keep=%E2%9C%93#section",+                canonicalURLString: "https://backup.test/work/constellation",+                note: "Preserve this note exactly — including Unicode.",+                rating: .down+            )+        )+        let work = try await repository.createWork(+            NewWorkDraft(displayTitle: "Constellation Work", hostname: "backup.test")+        )+        try await repository.moveEntry(entry.id, to: .existing(work.id))+        try await repository.updateWork(+            id: work.id,+            draft: WorkMetadataDraft(+                displayTitle: "Constellation Work Revised",+                type: .novel,+                genreTags: ["science fiction", "serial"],+                genericNotes: "Work-level notes"+            )+        )++        let stagingDirectory = fixture.baseDirectory.appending(path: "validated-backups")+        let exporter = BackupExporter(repository: repository, stagingDirectory: stagingDirectory)+        let exportedAt = Date(timeIntervalSince1970: 1_784_246_400)+        let result = try await exporter.export(+            metadata: BackupMetadata(+                appBuild: "integration-1",+                databaseSchemaVersion: 1,+                exportedAt: exportedAt+            )+        )+        defer { exporter.cleanup(result) }++        let encoded = try Data(contentsOf: result.fileURL)+        let decoded = try BackupV1Codec.decode(encoded)+        let source = try await repository.backupSnapshot()+        try BackupV1Validator.validate(decoded: decoded, source: source, encodedData: encoded)++        #expect(decoded.header.entryCount == 1)+        #expect(decoded.header.workCount == 1)+        #expect(decoded.snapshot.entries.first?.id == entry.id)+        #expect(decoded.snapshot.entries.first?.note == "Preserve this note exactly — including Unicode.")+        #expect(decoded.snapshot.entries.first?.workID == work.id)+        #expect(decoded.snapshot.works.first?.displayTitle == "Constellation Work Revised")+        #expect(decoded.snapshot.works.first?.entryIDs == [entry.id])+        #expect(decoded.snapshot.sites.map(\.hostname) == ["backup.test"])+    }++    private func initializeLibrary(at configuration: LibraryConfiguration) async throws {+        let repository = try await LibraryRepository.open(configuration)+        _ = try await repository.capture(+            CaptureDraft(+                captureTitle: "Durable record",+                captureTitleSource: .manual,+                rawURLString: "https://durable.test/1"+            )+        )+    }+}++private final class IntegrationFixture: @unchecked Sendable {+    let baseDirectory: URL+    let developmentConfiguration: LibraryConfiguration+    let personalConfiguration: LibraryConfiguration++    init() throws {+        baseDirectory = FileManager.default.temporaryDirectory+            .appending(path: "asterism-integration-\(UUID().uuidString)", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)+        developmentConfiguration = LibraryConfiguration(+            rootDirectory: baseDirectory.appending(path: "development", directoryHint: .isDirectory),+            environment: .development+        )+        personalConfiguration = LibraryConfiguration(+            rootDirectory: baseDirectory.appending(path: "personal", directoryHint: .isDirectory),+            environment: .personal+        )+    }++    func cleanup() {+        try? FileManager.default.removeItem(at: baseDirectory)+    }+}
Asterism/AsterismTests/MoveToAndNewWorkModelTests.swift Added +142 / -0
diff --git a/Asterism/AsterismTests/MoveToAndNewWorkModelTests.swift b/Asterism/AsterismTests/MoveToAndNewWorkModelTests.swiftnew file mode 100644index 0000000..33ef2d3--- /dev/null+++ b/Asterism/AsterismTests/MoveToAndNewWorkModelTests.swift@@ -0,0 +1,142 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for MoveToModel and NewWorkFormModel: destinations, assignment,+/// new work creation, submission suppression, failure handling.+@Suite("MoveToModel")+struct MoveToModelTests {++    @Test("Load populates same-host destinations")+    @MainActor func loadDestinations() async {+        let mock = MockLibraryProvider()+        let work1 = TestFixtures.makeWork(displayTitle: "Work A")+        let work2 = TestFixtures.makeWork(displayTitle: "Work B")+        mock.workDestinationsResult = .success([work1, work2])++        let entryID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: {})+        await model.load()+        #expect(model.state == .ready)+        #expect(model.destinations.count == 2)+    }++    @Test("Move to existing Work triggers moveEntry")+    @MainActor func moveToExisting() async {+        let mock = MockLibraryProvider()+        mock.workDestinationsResult = .success([])+        let tracker = CallbackTracker()+        let entryID = UUID()+        let workID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: { tracker.mutationCount += 1 })+        await model.load()+        await model.moveToExisting(workID)+        #expect(mock.moveEntryCallCount == 1)+        #expect(mock.lastMoveEntryID == entryID)+        #expect(mock.lastMoveDestination == .existing(workID))+        #expect(model.state == .completed)+        #expect(tracker.mutationCount == 1)+    }++    @Test("Move to new Work uses newWorkTitle")+    @MainActor func moveToNewWork() async {+        let mock = MockLibraryProvider()+        mock.workDestinationsResult = .success([])+        let entryID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: {})+        await model.load()+        model.newWorkTitle = "Brand New Work"+        await model.moveToNewWork()+        #expect(mock.lastMoveDestination == .newWork(displayTitle: "Brand New Work"))+        #expect(model.state == .completed)+    }++    @Test("Leave unattached")+    @MainActor func leaveUnattached() async {+        let mock = MockLibraryProvider()+        mock.workDestinationsResult = .success([])+        let entryID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: {})+        await model.load()+        await model.leaveUnattached()+        #expect(mock.lastMoveDestination == .unattached)+        #expect(model.state == .completed)+    }++    @Test("Move failure preserves state and shows error")+    @MainActor func moveFailure() async {+        let mock = MockLibraryProvider()+        mock.workDestinationsResult = .success([])+        mock.moveEntryResult = .failure(MockLibraryProvider.MockError.simulatedFailure("assignment failed"))+        let entryID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: {})+        await model.load()+        await model.moveToExisting(UUID())+        guard case .error = model.state else {+            Issue.record("Expected error state"); return+        }+        #expect(model.errorMessage != nil)+    }++    @Test("Duplicate move submission is suppressed")+    @MainActor func duplicateMoveSuppressed() async {+        let mock = MockLibraryProvider()+        mock.workDestinationsResult = .success([])+        let entryID = UUID()+        let model = MoveToModel(entryID: entryID, library: mock, onMutation: {})+        await model.load()+        async let first: () = model.moveToExisting(UUID())+        async let second: () = model.moveToExisting(UUID())+        _ = await (first, second)+        #expect(mock.moveEntryCallCount == 1)+    }+}++@Suite("NewWorkFormModel")+struct NewWorkFormModelTests {++    @Test("Create commits draft and transitions to created")+    @MainActor func createSuccess() async {+        let mock = MockLibraryProvider()+        let createdWork = TestFixtures.makeWork(displayTitle: "New Work")+        mock.createWorkResult = .success(createdWork)+        let tracker = CallbackTracker()+        let model = NewWorkFormModel(library: mock, onMutation: { tracker.mutationCount += 1 })+        model.draftTitle = "New Work"+        model.draftHostname = "example.com"+        await model.create()+        guard case .created(let result) = model.state else {+            Issue.record("Expected created state"); return+        }+        #expect(result.displayTitle == "New Work")+        #expect(tracker.mutationCount == 1)+    }++    @Test("Create failure preserves editing state")+    @MainActor func createFailure() async {+        let mock = MockLibraryProvider()+        mock.createWorkResult = .failure(MockLibraryProvider.MockError.simulatedFailure("create failed"))+        let model = NewWorkFormModel(library: mock, onMutation: {})+        model.draftTitle = "Failing Work"+        model.draftHostname = "bad"+        await model.create()+        guard case .error = model.state else {+            Issue.record("Expected error state"); return+        }+        #expect(model.errorMessage != nil)+    }++    @Test("Duplicate create is suppressed")+    @MainActor func duplicateCreateSuppressed() async {+        let mock = MockLibraryProvider()+        mock.createWorkResult = .success(TestFixtures.makeWork())+        let model = NewWorkFormModel(library: mock, onMutation: {})+        model.draftTitle = "Work"+        model.draftHostname = "example.com"+        async let first: () = model.create()+        async let second: () = model.create()+        _ = await (first, second)+        #expect(mock.createWorkCallCount == 1)+    }+}
Asterism/AsterismTests/SettingsBackupModelTests.swift Added +260 / -0
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftnew file mode 100644index 0000000..a1593b1--- /dev/null+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -0,0 +1,260 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// MARK: - SettingsBackupModel Tests++@Suite("Settings backup view model")+struct SettingsBackupModelTests {++    // MARK: - Initial State++    @Test("Starts in idle state")+    @MainActor func initialState() {+        let mock = MockBackupExporting()+        let model = SettingsBackupModel(exporter: mock)+        #expect(model.state == .idle)+        #expect(model.exportedFileURL == nil)+        #expect(model.errorMessage == nil)+    }++    // MARK: - Happy Path: Export Success++    @Test("Export transitions through exporting to sharing on success")+    @MainActor func exportSuccess() async {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }++        let fakeURL = tempDir.appending(path: "Asterism-backup-test.json")+        try? Data("{}".utf8).write(to: fakeURL)++        let mock = MockBackupExporting()+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        #expect(model.state == .sharing)+        #expect(model.exportedFileURL == fakeURL)+        #expect(model.errorMessage == nil)+        #expect(mock.exportCallCount == 1)+    }++    // MARK: - Failure Path: Export Error++    @Test("Export transitions to failed state on snapshot failure")+    @MainActor func exportFailure() async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(MockBackupError.snapshotFailed)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        #expect(model.state == .failed)+        #expect(model.exportedFileURL == nil)+        // Error message should be non-sensitive (no titles/notes/URLs)+        #expect(model.errorMessage != nil)+        #expect(mock.exportCallCount == 1)+    }++    @Test("Export transitions to failed state on encoding failure")+    @MainActor func exportEncodingFailure() async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(MockBackupError.encodingFailed)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        #expect(model.state == .failed)+        #expect(model.errorMessage != nil)+    }++    @Test("Export transitions to failed state on validation failure")+    @MainActor func exportValidationFailure() async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(MockBackupError.validationFailed)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        #expect(model.state == .failed)+        #expect(model.errorMessage != nil)+    }++    // MARK: - Duplicate Submission Suppression++    @Test("Suppresses duplicate export while already exporting")+    @MainActor func duplicateSubmissionSuppressed() async {+        let mock = MockBackupExporting()+        // Use a delay to simulate in-flight export+        mock.exportDelay = .milliseconds(100)+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }+        let fakeURL = tempDir.appending(path: "Asterism-backup-test.json")+        try? Data("{}".utf8).write(to: fakeURL)+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))++        let model = SettingsBackupModel(exporter: mock)++        // Start export but don't await+        async let _ = model.startExport()+        // Immediately try another — should be suppressed+        await model.startExport()++        // Only one actual export call+        #expect(mock.exportCallCount == 1)+    }++    // MARK: - Share Completion Cleanup++    @Test("Completing share cleans up the staged file and returns to idle")+    @MainActor func shareCompletionCleanup() async {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }++        let fakeURL = tempDir.appending(path: "Asterism-backup-test.json")+        try? Data("test".utf8).write(to: fakeURL)++        let mock = MockBackupExporting()+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()+        #expect(model.state == .sharing)++        model.handleShareCompletion()++        #expect(model.state == .idle)+        #expect(model.exportedFileURL == nil)+        #expect(mock.cleanupCallCount == 1)+        #expect(mock.lastCleanupURL == fakeURL)+    }++    // MARK: - Cancellation Cleanup++    @Test("Cancelling share cleans up the staged file and returns to idle")+    @MainActor func shareCancellationCleanup() async {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }++        let fakeURL = tempDir.appending(path: "Asterism-backup-test.json")+        try? Data("test".utf8).write(to: fakeURL)++        let mock = MockBackupExporting()+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()+        #expect(model.state == .sharing)++        model.handleShareCancellation()++        #expect(model.state == .idle)+        #expect(model.exportedFileURL == nil)+        #expect(mock.cleanupCallCount == 1)+    }++    // MARK: - Retry After Failure++    @Test("Can retry after a failure")+    @MainActor func retryAfterFailure() async {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }+        let fakeURL = tempDir.appending(path: "Asterism-backup-retry.json")+        try? Data("{}".utf8).write(to: fakeURL)++        let mock = MockBackupExporting()+        mock.exportResult = .failure(MockBackupError.snapshotFailed)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()+        #expect(model.state == .failed)++        // Now fix the mock and retry+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))+        await model.startExport()+        #expect(model.state == .sharing)+        #expect(model.exportedFileURL == fakeURL)+    }++    // MARK: - Privacy-Safe Error Messages++    @Test("Error message does not leak user data")+    @MainActor func privacySafeErrorMessage() async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(MockBackupError.snapshotFailed)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        guard let message = model.errorMessage else {+            Issue.record("Expected an error message")+            return+        }+        // Must not contain any URL, title, note, or filename content+        #expect(!message.contains("https://"))+        #expect(!message.contains("file://"))+        #expect(message.contains("backup") || message.contains("Backup") || message.contains("export") || message.contains("Export"))+    }++    // MARK: - Scavenging on Init++    @Test("Scavenges stale files on initialization")+    @MainActor func scavengesOnInit() {+        let mock = MockBackupExporting()+        let _ = SettingsBackupModel(exporter: mock)+        #expect(mock.scavengeCallCount == 1)+    }+}++// MARK: - Test Doubles++/// Mock that records calls and returns preset results.+final class MockBackupExporting: BackupExporting, @unchecked Sendable {+    var exportCallCount = 0+    var cleanupCallCount = 0+    var scavengeCallCount = 0+    var lastCleanupURL: URL?++    var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured)+    var exportDelay: Duration?++    func export(metadata: BackupMetadata) async throws -> BackupExportResult {+        exportCallCount += 1+        if let delay = exportDelay {+            try? await Task.sleep(for: delay)+        }+        return try exportResult.get()+    }++    func cleanup(_ result: BackupExportResult) {+        cleanupCallCount += 1+        lastCleanupURL = result.fileURL+    }++    func scavengeStaleFiles() {+        scavengeCallCount += 1+    }+}++enum MockBackupError: Error, LocalizedError {+    case notConfigured+    case snapshotFailed+    case encodingFailed+    case validationFailed++    var errorDescription: String? {+        switch self {+        case .notConfigured: "Mock not configured"+        case .snapshotFailed: "Backup snapshot reading failed"+        case .encodingFailed: "Backup encoding failed"+        case .validationFailed: "Backup validation failed"+        }+    }+}
Asterism/AsterismTests/UITestLaunchSupportTests.swift Added +67 / -0
diff --git a/Asterism/AsterismTests/UITestLaunchSupportTests.swift b/Asterism/AsterismTests/UITestLaunchSupportTests.swiftnew file mode 100644index 0000000..a756a7e--- /dev/null+++ b/Asterism/AsterismTests/UITestLaunchSupportTests.swift@@ -0,0 +1,67 @@+import Foundation+import Testing+@testable import Asterism+import AsterismCore++@Suite("UI test launch validation")+struct UITestLaunchSupportTests {+    @Test("Normal launches do not request a test library")+    func disabledWithoutScenario() {+        let request = UITestLaunchSupport.request(+            environmentProvider: StubProcessEnvironment(values: [:]),+            temporaryDirectory: URL(filePath: "/tmp/asterism-launch-tests")+        )+        #expect(request == .disabled)+    }++    @Test("Seeded launches use a contained Development-only path")+    func validSeedRequest() throws {+        let runID = UUID()+        let temporaryDirectory = URL(filePath: "/tmp/asterism-launch-tests", directoryHint: .isDirectory)+        let request = UITestLaunchSupport.request(+            environmentProvider: StubProcessEnvironment(+                values: [+                    UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScenario,+                    UITestLaunchSupport.runIDKey: runID.uuidString,+                ]+            ),+            temporaryDirectory: temporaryDirectory+        )++        guard case .seeded(let configuration) = request else {+            Issue.record("Expected a seeded launch, got \(request)")+            return+        }+        #expect(configuration.environment == .development)+        #expect(configuration.rootDirectory.path.hasPrefix(temporaryDirectory.path + "/AsterismUITests/"))+        #expect(configuration.rootDirectory.lastPathComponent == runID.uuidString)+    }++    @Test(+        "Malformed launch input fails closed",+        arguments: [+            [UITestLaunchSupport.scenarioKey: "unknown"],+            [UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScenario],+            [+                UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScenario,+                UITestLaunchSupport.runIDKey: "../../personal-library",+            ],+        ]+    )+    func invalidRequests(values: [String: String]) {+        let request = UITestLaunchSupport.request(+            environmentProvider: StubProcessEnvironment(values: values),+            temporaryDirectory: URL(filePath: "/tmp/asterism-launch-tests")+        )+        guard case .invalid(let message) = request else {+            Issue.record("Expected invalid launch request, got \(request)")+            return+        }+        #expect(!message.isEmpty)+    }+}++private struct StubProcessEnvironment: ProcessEnvironmentProviding {+    let values: [String: String]+    var environment: [String: String] { values }+}
Asterism/AsterismTests/WorkDetailModelTests.swift Added +100 / -0
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftnew file mode 100644index 0000000..8c3e30a--- /dev/null+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -0,0 +1,100 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for WorkDetailModel: drafts, save, failure without optimistic mutation,+/// submission suppression.+@Suite("WorkDetailModel")+struct WorkDetailModelTests {++    @MainActor private func makeSUT(+        work: WorkSnapshot? = nil,+        updateResult: Result<Void, Error> = .success(())+    ) -> (WorkDetailModel, MockLibraryProvider, CallbackTracker) {+        let mock = MockLibraryProvider()+        let workSnapshot = work ?? TestFixtures.makeWork(+            displayTitle: "Original Title",+            type: .novel,+            genreTags: ["fantasy"],+            genericNotes: "some notes"+        )+        mock.workResult = .success(workSnapshot)+        mock.updateWorkResult = updateResult++        let tracker = CallbackTracker()+        let model = WorkDetailModel(+            workID: workSnapshot.id,+            library: mock,+            onMutation: { tracker.mutationCount += 1 }+        )+        return (model, mock, tracker)+    }++    @Test("Load populates work and drafts")+    @MainActor func loadPopulates() async {+        let work = TestFixtures.makeWork(+            displayTitle: "My Work",+            type: .toon,+            genreTags: ["action", "comedy"],+            genericNotes: "great story"+        )+        let (model, _, _) = makeSUT(work: work)+        await model.load()+        #expect(model.state == .ready)+        #expect(model.work == work)+        #expect(model.draftTitle == "My Work")+        #expect(model.draftType == .toon)+        #expect(model.draftTags == ["action", "comedy"])+        #expect(model.draftNotes == "great story")+    }++    @Test("Save commits metadata changes and triggers mutation callback")+    @MainActor func saveCommits() async {+        let (model, mock, tracker) = makeSUT()+        await model.load()+        model.draftTitle = "New Title"+        model.draftType = .article+        model.draftTags = ["sci-fi"]+        model.draftNotes = "updated"+        await model.save()+        #expect(mock.updateWorkCallCount == 1)+        #expect(tracker.mutationCount == 1)+    }++    @Test("Save failure restores drafts from prior snapshot")+    @MainActor func saveFailureRestores() async {+        let work = TestFixtures.makeWork(+            displayTitle: "Prior Title",+            type: .novel,+            genreTags: ["drama"],+            genericNotes: "prior notes"+        )+        let (model, _, _) = makeSUT(+            work: work,+            updateResult: .failure(MockLibraryProvider.MockError.simulatedFailure("update failed"))+        )+        await model.load()+        model.draftTitle = "Changed"+        model.draftType = .toon+        model.draftTags = ["new-tag"]+        model.draftNotes = "changed notes"+        await model.save()+        // Restored from snapshot+        #expect(model.draftTitle == "Prior Title")+        #expect(model.draftType == .novel)+        #expect(model.draftTags == ["drama"])+        #expect(model.draftNotes == "prior notes")+        #expect(model.errorMessage != nil)+    }++    @Test("Duplicate save is suppressed")+    @MainActor func duplicateSaveSuppressed() async {+        let (model, mock, _) = makeSUT()+        await model.load()+        async let first: () = model.save()+        async let second: () = model.save()+        _ = await (first, second)+        #expect(mock.updateWorkCallCount == 1)+    }+}
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Added +170 / -0
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftnew file mode 100644index 0000000..072e875--- /dev/null+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -0,0 +1,170 @@+import XCTest++final class AccessibilityJourneyUITests: XCTestCase {+    private var app: XCUIApplication!++    override func setUp() {+        continueAfterFailure = false+        app = XCUIApplication()+    }++    override func tearDown() {+        app?.terminate()+        app = nil+    }++    @MainActor+    func testSeededRecentEntryAssignmentAndWorkJourney() {+        launchSeeded()++        let recentList = app.collectionViews["recent-list"]+        XCTAssertTrue(recentList.waitForExistence(timeout: 30), "Seeded Recent list should load")+        let chapter = app.buttons["Open entry Integration Chapter from integration.test"]+        XCTAssertTrue(chapter.waitForExistence(timeout: 10))+        chapter.tap()++        scrollToElement(app.buttons["entry-detail-update-button"])+        assertContentControl(app.buttons["entry-detail-update-button"], named: "Update")+        scrollToElement(app.buttons["entry-detail-move-to-button"])+        assertContentControl(app.buttons["entry-detail-move-to-button"], named: "Move to")+        app.buttons["entry-detail-move-to-button"].tap()++        let existingWork = app.buttons.matching(identifier: "move-to-work-destination").firstMatch+        XCTAssertTrue(existingWork.waitForExistence(timeout: 10), "A same-host Work destination should be exposed")+        assertContentControl(existingWork, named: "Same-host Work destination")+        existingWork.tap()+        XCTAssertTrue(+            app.navigationBars["Move to"].waitForNonExistence(timeout: 10),+            "Move-to sheet should dismiss after assignment"+        )++        let worksTab = app.tabBars.buttons["Works"]+        XCTAssertTrue(worksTab.waitForExistence(timeout: 10))+        worksTab.tap()+        let workTitle = app.buttons["Open Work Integration Work from integration.test"]+        XCTAssertTrue(workTitle.waitForExistence(timeout: 10))+        workTitle.tap()++        let assignedEntry = app.staticTexts.matching(identifier: "work-detail-entry").firstMatch+        scrollToElement(assignedEntry)+        XCTAssertTrue(assignedEntry.waitForExistence(timeout: 10))+        let saveWork = app.buttons["work-detail-save-button"]+        scrollToElement(saveWork)+        assertContentControl(saveWork, named: "Save Work")+    }++    @MainActor+    func testMoveToOffersNewWorkAndLeaveUnattachedActions() {+        launchSeeded()+        openSeededEntry()++        let moveButton = app.buttons["entry-detail-move-to-button"]+        scrollToElement(moveButton)+        moveButton.tap()++        let newWork = app.buttons["move-to-new-work-button"]+        let leaveUnattached = app.buttons["move-to-leave-unattached-button"]+        scrollToElement(newWork)+        assertContentControl(newWork, named: "New Work")+        scrollToElement(leaveUnattached)+        assertContentControl(leaveUnattached, named: "Leave unattached")+    }++    @MainActor+    func testSettingsBackupSurfaceHasReachableAction() {+        launchSeeded()++        let settingsButton = app.buttons["settings-button"]+        XCTAssertTrue(settingsButton.waitForExistence(timeout: 30))+        assertSystemControl(settingsButton, named: "Settings")+        settingsButton.tap()++        let exportButton = app.buttons["settings-export-backup-button"]+        XCTAssertTrue(exportButton.waitForExistence(timeout: 10))+        assertContentControl(exportButton, named: "Export Backup")+        XCTAssertTrue(app.collectionViews["settings-view"].exists)+    }++    @MainActor+    func testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable() {+        launchSeeded(+            extraArguments: [+                "-AppleInterfaceStyle", "Dark",+                "-UIAccessibilityReduceTransparency", "YES",+                "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge",+            ]+        )+        openSeededEntry()++        let moveButton = app.buttons["entry-detail-move-to-button"]+        scrollToElement(moveButton, attempts: 8)+        assertContentControl(moveButton, named: "Move to at largest Dynamic Type")+        moveButton.tap()++        let leaveUnattached = app.buttons["move-to-leave-unattached-button"]+        scrollToElement(leaveUnattached, attempts: 8)+        assertContentControl(leaveUnattached, named: "Leave unattached at largest Dynamic Type")+    }++    @MainActor+    func testLightAppearanceExposesLabelsAndMinimumHitTargets() {+        launchSeeded(extraArguments: ["-AppleInterfaceStyle", "Light"])++        let settings = app.buttons["settings-button"]+        let works = app.tabBars.buttons["Works"]+        XCTAssertTrue(settings.waitForExistence(timeout: 30))+        assertSystemControl(settings, named: "Settings")+        assertSystemControl(works, named: "Works tab")++        works.tap()+        let newWork = app.buttons["works-new-work-button"]+        XCTAssertTrue(newWork.waitForExistence(timeout: 10))+        assertSystemControl(newWork, named: "New Work")+    }++    private func openSeededEntry() {+        let chapter = app.buttons["Open entry Integration Chapter from integration.test"]+        XCTAssertTrue(chapter.waitForExistence(timeout: 30))+        chapter.tap()+    }++    private func launchSeeded(extraArguments: [String] = []) {+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launchArguments += extraArguments+        app.launch()+    }++    private func scrollToElement(_ element: XCUIElement, attempts: Int = 5) {+        for _ in 0..<attempts where !element.isHittable {+            app.swipeUp()+        }+    }++    /// Content controls own their complete hit rectangle, so XCUI's visual frame+    /// is the effective target and can be checked directly.+    private func assertContentControl(+        _ element: XCUIElement,+        named name: String,+        file: StaticString = #filePath,+        line: UInt = #line+    ) {+        assertSystemControl(element, named: name, file: file, line: line)+        XCTAssertGreaterThanOrEqual(element.frame.width, 44, "\(name) should be at least 44 points wide", file: file, line: line)+        XCTAssertGreaterThanOrEqual(element.frame.height, 44, "\(name) should be at least 44 points tall", file: file, line: line)+    }++    /// UIKit navigation and tab bars provide system-managed hit slop outside+    /// their clipped visual frame; existence, hittability, and labels are the+    /// observable accessibility contract for those controls.+    private func assertSystemControl(+        _ element: XCUIElement,+        named name: String,+        file: StaticString = #filePath,+        line: UInt = #line+    ) {+        XCTAssertTrue(element.waitForExistence(timeout: 10), "\(name) should exist", file: file, line: line)+        XCTAssertTrue(element.isHittable, "\(name) should be reachable", file: file, line: line)+        XCTAssertFalse(element.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, "\(name) needs an accessibility label", file: file, line: line)+    }+}
Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift Added +58 / -0
diff --git a/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swiftnew file mode 100644index 0000000..41bbd21--- /dev/null+++ b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift@@ -0,0 +1,58 @@+import XCTest++/// UI tests for the Recent tab and Entry detail views.+/// Covers: two-tab entry, raw Recent rows without blanket amber,+/// Entry detail update/delete, deletion failure, ordering, and accessibility identifiers.+final class RecentAndEntryDetailUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launch()+    }++    func testTwoTabStructure() {+        // Verify both tabs exist+        let recentTab = app.tabBars.buttons["Recent"]+        let worksTab = app.tabBars.buttons["Works"]+        // At least one tab bar item should be present once the app is ready+        let exists = recentTab.waitForExistence(timeout: 30) || worksTab.waitForExistence(timeout: 10)+        XCTAssertTrue(exists, "Expected tab bar to be present when app is ready")+    }++    func testRecentShowsEmptyStateInitially() {+        // Wait for the app to finish loading+        let emptyView = app.otherElements["recent-empty"]+        if emptyView.waitForExistence(timeout: 10) {+            XCTAssertTrue(emptyView.exists, "Expected empty Recent state for a fresh library")+        }+        // If not empty (library has data from prior test), that's acceptable too+    }++    func testRecentRowsHaveNoAmberTreatment() {+        // M1 rows should use normal treatment — no amber border or Teach pill.+        // This test verifies the absence of amber-specific accessibility identifiers.+        let amberTeachPill = app.buttons["teach-pill"]+        XCTAssertFalse(amberTeachPill.exists, "M1 Recent rows must not show Teach pill")+    }++    func testAccessibilityIdentifiersPresent() {+        // Verify key accessibility identifiers are available once app loads+        let loadingView = app.otherElements["app-loading"]+        // Either loading shows, then ready, or directly ready+        if loadingView.waitForExistence(timeout: 2) {+            // Wait for it to disappear+            let disappeared = loadingView.waitForNonExistence(timeout: 10)+            XCTAssertTrue(disappeared, "App should finish loading")+        }++        // Seeded UI launches must resolve to a concrete Recent list.+        let recentList = app.collectionViews["recent-list"]+        XCTAssertTrue(+            recentList.waitForExistence(timeout: 30),+            "Expected Recent view after app becomes ready"+        )+    }+}
Asterism/AsterismUITests/WorksAndAssignmentUITests.swift Added +57 / -0
diff --git a/Asterism/AsterismUITests/WorksAndAssignmentUITests.swift b/Asterism/AsterismUITests/WorksAndAssignmentUITests.swiftnew file mode 100644index 0000000..8c4bf4f--- /dev/null+++ b/Asterism/AsterismUITests/WorksAndAssignmentUITests.swift@@ -0,0 +1,57 @@+import XCTest++/// UI tests for Works tab, Work detail, and assignment flows.+final class WorksAndAssignmentUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launch()+    }++    func testWorksTabAccessible() {+        let worksTab = app.tabBars.buttons["Works"]+        XCTAssertTrue(worksTab.waitForExistence(timeout: 30), "Works tab not found")+        worksTab.tap()++        let worksList = app.collectionViews["works-list"]+        XCTAssertTrue(worksList.waitForExistence(timeout: 10), "Expected Works list view after tab selection")+    }++    func testNewWorkButtonAlwaysAvailable() {+        let worksTab = app.tabBars.buttons["Works"]+        XCTAssertTrue(worksTab.waitForExistence(timeout: 30), "Works tab not found")+        worksTab.tap()++        let newWorkButton = app.buttons["works-new-work-button"]+        XCTAssertTrue(+            newWorkButton.waitForExistence(timeout: 10),+            "New Work button must be available even with no Works"+        )+    }++    func testUnattachedSectionPresent() {+        let worksTab = app.tabBars.buttons["Works"]+        XCTAssertTrue(worksTab.waitForExistence(timeout: 30), "Works tab not found")+        worksTab.tap()++        let unattachedHeader = app.staticTexts["unattached-section-header"]+        XCTAssertTrue(+            unattachedHeader.waitForExistence(timeout: 10),+            "Unattached notes section must always be present"+        )+    }++    func testNewWorkSystemControlIsLabelledAndHittable() {+        let worksTab = app.tabBars.buttons["Works"]+        XCTAssertTrue(worksTab.waitForExistence(timeout: 30), "Works tab not found")+        worksTab.tap()++        let newWorkButton = app.buttons["works-new-work-button"]+        XCTAssertTrue(newWorkButton.waitForExistence(timeout: 10))+        XCTAssertTrue(newWorkButton.isHittable)+        XCTAssertFalse(newWorkButton.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)+    }+}
CHANGELOG.md Modified +17 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex a4d0b6c..fe04df1 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,14 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Fixed++- Fixed Safari share captures reporting that no URL was provided when Safari supplies only JavaScript preprocessing metadata, by accepting its authoritative `location.href` without requiring a redundant URL attachment.+ ### Added +- Added cross-target integration coverage for configuration-isolated extension capture, app activation refresh, fresh-process assignment visibility, fail-closed restart, and complete real-record backups.+- Added deterministic, validated UI-test libraries and simulator journeys for M1 navigation, assignment, backup, light/dark appearance, Reduce Transparency, Dynamic Type, labels, and hit targets.+- Added the end-to-end share capture flow with ordered provider loading, Safari metadata preprocessing, bounded network title acquisition, required manual-title fallback, focused note/rating UI, cancellation handling, and exactly-once extension completion.+- Added deterministic Backup V1 encoding, strict decoding and validation, coherent repository snapshots, protected file staging, stale-file cleanup, and Settings-based system sharing.+- Added Recent, Works, Entry detail, Work editing, assignment, and Settings surfaces backed by refreshable snapshot view models with explicit retry and failure states.+- Added test seams and coverage for provider ordering/cancellation, redirect and byte-bound network limits, capture lifecycle edge cases, canonical backup fixtures, app mutations, backup cleanup, and accessibility identifiers.+- Added the local `AsterismCore` package with the versioned V1 SwiftData schema, validated value objects, immutable snapshots, millisecond clock boundary, and lexical URL/hostname normalization.+- Added a fail-closed shared-library repository with bounded POSIX locking, retained containers, marker-protected bootstrap, atomic capture and curation operations, deterministic Recent/Works snapshots, and save-failure recovery through fresh contexts.+- Added isolated Personal and Development app/share-extension targets, schemes, App Groups, store settings, Safari preprocessing integration, and helper-process coverage for cross-process persistence behavior.+- Added unit coverage for schema invariants, persistence round trips, URL identity properties, lock contention/cancellation/process death, bootstrap races and corruption, repository rollback, Work metadata, and assignment semantics. - Added a help-first iOS development Makefile with simulator builds, focused and full tests, physical-device workflows, Release recursion, archiving, package resolution, optional `xcbeautify`, and reliable pipeline failure handling. - Added the approved M1 Immutable Capture & Safety Net requirements, architecture, decision log, implementation task plan, and Apple Developer prerequisites. - Added explicit Personal and Development library isolation, plain Entry deletion, editable Work metadata, CloudKit-compatible optional relationship storage, and validated Backup V1 contracts.  ### Changed +- Closed the capture save-state race by publishing in-flight state before persistence, narrowed unattached-entry reads to a SwiftData predicate, corrected Personal archive configuration, and documented implementation completeness.+- Completed M1 app-extension integration by retaining destination view models, separating per-tab navigation state, exposing Move to from Entry detail, and aligning extension metadata and test membership across Personal and Development schemes.+- Made app and extension startup fail closed when the configured App Group is unavailable, preventing fallback stores from masking missing or inaccessible libraries. - Expanded repository ignore rules for Xcode user state, local build products, Swift tooling, archives, test results, IDE metadata, and temporary files while keeping local source packages trackable. - Removed previously tracked Xcode per-user workspace and scheme-management state from version control. - Reworked the M1 implementation plan into 31 dependency-linked tasks with strict adjacent red/green TDD pairs across foundation, capture, backup, app, and integration streams.
Makefile Modified +30 / -24
diff --git a/Makefile b/Makefileindex 16b7228..08b26ac 100644--- a/Makefile+++ b/Makefile@@ -5,13 +5,17 @@ SHELL = /bin/bash .DEFAULT_GOAL := help  PROJECT ?= Asterism/Asterism.xcodeproj-SCHEME ?= Asterism-BUNDLE_ID ?= me.nore.ig.Asterism-CONFIG ?= Debug+SCHEME ?= Asterism Development+BUNDLE_ID ?= me.nore.ig.Asterism.dev+CONFIG ?= Development SIMULATOR ?= iPhone 17 Pro DERIVED_DATA ?= ./DerivedData ARCHIVE_PATH ?= ./build/$(SCHEME)-ios.xcarchive +# Built product name (PRODUCT_NAME), which differs from the scheme name.+# Every configuration builds "Asterism.app".+APP_NAME ?= Asterism+ UNIT_TEST_BUNDLE ?= AsterismTests UI_TEST_BUNDLE ?= AsterismUITests DESTINATION = platform=iOS Simulator,name=$(SIMULATOR)@@ -59,7 +63,7 @@ build: build-ios build-ios: 	$(PIPEFAIL) xcodebuild build \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(DEST_TIMEOUT) \ 		-destination '$(DESTINATION)' \ 		-configuration $(CONFIG) \@@ -70,10 +74,10 @@ build-ios: test-quick: 	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(DEST_TIMEOUT) \ 		-destination '$(DESTINATION)' \-		-configuration Debug \+		-configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) \ 		-only-testing:$(UNIT_TEST_BUNDLE) \ 		-parallel-testing-worker-count 1 \@@ -83,10 +87,10 @@ test-quick: test: 	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(DEST_TIMEOUT) \ 		-destination '$(DESTINATION)' \-		-configuration Debug \+		-configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) \ 		-parallel-testing-worker-count 1 \ 		-maximum-concurrent-test-simulator-destinations 1 \@@ -96,10 +100,10 @@ test: test-ui: 	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(DEST_TIMEOUT) \ 		-destination '$(DESTINATION)' \-		-configuration Debug \+		-configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) \ 		-only-testing:$(UI_TEST_BUNDLE) \ 		-parallel-testing-worker-count 1 \@@ -115,24 +119,26 @@ test-only: 	fi 	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(DEST_TIMEOUT) \ 		-destination '$(DESTINATION)' \-		-configuration Debug \+		-configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) \ 		-only-testing:$(TEST) \ 		-parallel-testing-worker-count 1 \ 		-maximum-concurrent-test-simulator-destinations 1 \ 		$(PIPE_PRETTY) -# Leave DEVICE_MODEL empty to use the first connected device. Set it to a-# marketing name to disambiguate when more than one device is connected.-DEVICE_MODEL ?=+# Defaults to the iPhone 17 Pro. Override DEVICE_MODEL with another marketing+# name to target a different device, or set it empty to use the first paired+# device. Network-paired devices sit at tunnelState "disconnected" until an+# operation wakes the tunnel, so match on pairingState rather than tunnelState.+DEVICE_MODEL ?= iPhone 17 Pro DEVICE_ID = $(shell tmp=$$(mktemp); \ 	xcrun devicectl list devices --json-output "$$tmp" >/dev/null 2>&1; \ 	jq -r --arg model "$(DEVICE_MODEL)" \ 		'.result.devices[] \-		 | select(.connectionProperties.tunnelState == "connected") \+		 | select(.connectionProperties.pairingState == "paired") \ 		 | select($$model == "" or .hardwareProperties.marketingName == $$model) \ 		 | .hardwareProperties.udid' "$$tmp" 2>/dev/null | head -1; \ 	rm -f "$$tmp")@@ -154,7 +160,7 @@ install: 	fi 	$(PIPEFAIL) xcodebuild build \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		-destination 'id=$(DEVICE_ID)' \ 		-configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) \@@ -162,7 +168,7 @@ install: 		$(PIPE_PRETTY) 	xcrun devicectl device install app \ 		--device $(DEVICE_ID) \-		$(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/$(SCHEME).app+		"$(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/$(APP_NAME).app"  .PHONY: run run: install@@ -170,21 +176,21 @@ run: install  .PHONY: build-release install-release run-release build-release:-	$(MAKE) build CONFIG=Release+	$(MAKE) build SCHEME="Asterism Personal" CONFIG=Personal  install-release:-	$(MAKE) install CONFIG=Release+	$(MAKE) install SCHEME="Asterism Personal" CONFIG=Personal BUNDLE_ID=me.nore.ig.Asterism  run-release:-	$(MAKE) run CONFIG=Release+	$(MAKE) run SCHEME="Asterism Personal" CONFIG=Personal BUNDLE_ID=me.nore.ig.Asterism  .PHONY: archive archive: 	$(PIPEFAIL) xcodebuild archive \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		-destination 'generic/platform=iOS' \-		-configuration Release \+		-configuration Personal \ 		-archivePath $(ARCHIVE_PATH) \ 		-allowProvisioningUpdates \ 		$(PIPE_PRETTY)@@ -194,7 +200,7 @@ archive: resolve: 	$(PIPEFAIL) xcodebuild -resolvePackageDependencies \ 		-project $(PROJECT) \-		-scheme $(SCHEME) \+		-scheme "$(SCHEME)" \ 		$(PIPE_PRETTY)  .PHONY: clean
Packages/AsterismCore/Package.swift Added +28 / -0
diff --git a/Packages/AsterismCore/Package.swift b/Packages/AsterismCore/Package.swiftnew file mode 100644index 0000000..4c619b6--- /dev/null+++ b/Packages/AsterismCore/Package.swift@@ -0,0 +1,28 @@+// swift-tools-version: 6.2++import PackageDescription++let package = Package(+    name: "AsterismCore",+    platforms: [+        .iOS(.v26),+        .macOS(.v26),+    ],+    products: [+        .library(name: "AsterismCore", targets: ["AsterismCore"]),+        .executable(name: "AsterismStoreTestHelper", targets: ["AsterismStoreTestHelper"]),+    ],+    targets: [+        .target(name: "AsterismCore"),+        .executableTarget(+            name: "AsterismStoreTestHelper",+            dependencies: ["AsterismCore"]+        ),+        .testTarget(+            name: "AsterismCoreTests",+            dependencies: ["AsterismCore"],+            resources: [.copy("Fixtures")]+        ),+    ],+    swiftLanguageModes: [.v6]+)
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swift Added +19 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swiftnew file mode 100644index 0000000..03527e8--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swift@@ -0,0 +1,19 @@+import SwiftData++public enum AsterismSchemaV1: VersionedSchema {+    public static let versionIdentifier = Schema.Version(1, 0, 0)++    public static var models: [any PersistentModel.Type] {+        [Entry.self, Work.self, Site.self, TitlePattern.self]+    }+}++public enum AsterismMigrationPlan: SchemaMigrationPlan {+    public static var schemas: [any VersionedSchema.Type] {+        [AsterismSchemaV1.self]+    }++    public static var stages: [MigrationStage] {+        []+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift Added +118 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftnew file mode 100644index 0000000..fdf81fe--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -0,0 +1,118 @@+import Foundation+import SwiftData++// MARK: - Backup Snapshot Providing Protocol++/// Protocol for providing backup snapshots. Enables test injection of failures.+public protocol BackupSnapshotProviding: Sendable {+    func backupSnapshot() async throws -> LibraryBackupSnapshot+}++// Conform LibraryRepository to the protocol+extension LibraryRepository: BackupSnapshotProviding {+    public func backupSnapshot() async throws -> LibraryBackupSnapshot {+        try await withLockedBackupContext { context in+            let entries = try context.fetch(FetchDescriptor<Entry>())+            let works = try context.fetch(FetchDescriptor<Work>())+            let sites = try context.fetch(FetchDescriptor<Site>())+            let patterns = try context.fetch(FetchDescriptor<TitlePattern>())++            return LibraryBackupSnapshot(+                entries: try entries.map(Self.mapEntryRecord),+                works: try works.map { try Self.mapWorkRecord($0) },+                sites: try sites.map(Self.mapSiteRecord),+                titlePatterns: try patterns.map(Self.mapTitlePatternRecord)+            )+        }+    }+}++// MARK: - Backup Export Result++/// Result of a successful backup export, containing the file URL for sharing.+public struct BackupExportResult: Sendable {+    public let fileURL: URL++    public init(fileURL: URL) {+        self.fileURL = fileURL+    }+}++// MARK: - Backup Exporter++/// Orchestrates backup snapshot → encode → validate → stage lifecycle.+/// Does not add restore/import.+public final class BackupExporter: @unchecked Sendable {+    private let repository: any BackupSnapshotProviding+    private let stagingDirectory: URL++    public init(repository: any BackupSnapshotProviding, stagingDirectory: URL) {+        self.repository = repository+        self.stagingDirectory = stagingDirectory+    }++    /// Exports a validated backup file. Throws on any failure; no file remains on failure.+    public func export(metadata: BackupMetadata) async throws -> BackupExportResult {+        // 1. Read point-in-time snapshot+        let snapshot = try await repository.backupSnapshot()++        // 2. Encode+        let encoded = try BackupV1Codec.encode(snapshot: snapshot, metadata: metadata)++        // 3. Decode and validate+        let decoded = try BackupV1Codec.decode(encoded)+        try BackupV1Validator.validate(decoded: decoded, source: snapshot, encodedData: encoded)++        // 4. Stage file+        try FileManager.default.createDirectory(at: stagingDirectory, withIntermediateDirectories: true)+        let filename = Self.generateFilename(exportedAt: metadata.exportedAt)+        let fileURL = stagingDirectory.appending(path: filename)++        do {+            try encoded.write(to: fileURL, options: [.atomic])+            // Set completeUnlessOpen protection+            try FileManager.default.setAttributes(+                [.protectionKey: FileProtectionType.completeUnlessOpen],+                ofItemAtPath: fileURL.path+            )+        } catch {+            // Clean up any partial file+            try? FileManager.default.removeItem(at: fileURL)+            throw BackupCodecError.encodingFailed(reason: "failed to stage backup file: \(error)")+        }++        return BackupExportResult(fileURL: fileURL)+    }++    /// Removes a previously exported file (after share completion or cancellation).+    public func cleanup(_ result: BackupExportResult) {+        try? FileManager.default.removeItem(at: result.fileURL)+    }++    /// Removes staged backup files older than 24 hours.+    public func scavengeStaleFiles() {+        guard let contents = try? FileManager.default.contentsOfDirectory(+            at: stagingDirectory,+            includingPropertiesForKeys: [.contentModificationDateKey],+            options: .skipsHiddenFiles+        ) else { return }++        let cutoff = Date().addingTimeInterval(-24 * 3600)+        for url in contents {+            guard url.lastPathComponent.hasPrefix("Asterism-backup-") else { continue }+            guard let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]),+                  let modDate = attrs.contentModificationDate,+                  modDate < cutoff else { continue }+            try? FileManager.default.removeItem(at: url)+        }+    }++    private static func generateFilename(exportedAt: Date) -> String {+        let formatter = ISO8601DateFormatter()+        formatter.formatOptions = [.withInternetDateTime]+        formatter.timeZone = TimeZone(identifier: "UTC")!+        let timestamp = formatter.string(from: exportedAt)+            .replacingOccurrences(of: ":", with: "-")+        return "Asterism-backup-\(timestamp).json"+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swift Added +290 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swiftnew file mode 100644index 0000000..b460e51--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swift@@ -0,0 +1,290 @@+import CryptoKit+import Foundation++// MARK: - Canonical JSON Writer++/// Writes canonical JSON per the V1 backup format spec:+/// - UTF-8, no BOM, no insignificant whitespace+/// - Object keys lexicographically sorted+/// - Strings: \", \\, \b, \f, \n, \r, \t; U+0000–001F use lowercase \u00xx; / and non-ASCII unescaped+/// - Integers: base-10 no leading zero; booleans/null lowercase+/// - Dates: UTC RFC 3339 with exactly three fractional digits+/// - UUIDs: lowercase canonical strings+internal struct CanonicalJSONWriter {+    private(set) var data = Data()++    mutating func writeNull() {+        data.append(contentsOf: "null".utf8)+    }++    mutating func writeBool(_ value: Bool) {+        data.append(contentsOf: (value ? "true" : "false").utf8)+    }++    mutating func writeInt(_ value: Int) {+        data.append(contentsOf: String(value).utf8)+    }++    mutating func writeString(_ value: String) {+        data.append(UInt8(ascii: "\""))+        for scalar in value.unicodeScalars {+            switch scalar {+            case "\"": data.append(contentsOf: "\\\"".utf8)+            case "\\": data.append(contentsOf: "\\\\".utf8)+            case "\u{08}": data.append(contentsOf: "\\b".utf8)+            case "\u{0C}": data.append(contentsOf: "\\f".utf8)+            case "\n": data.append(contentsOf: "\\n".utf8)+            case "\r": data.append(contentsOf: "\\r".utf8)+            case "\t": data.append(contentsOf: "\\t".utf8)+            case let s where s.value < 0x20:+                // Other control chars: \u00xx lowercase+                let hex = String(format: "\\u%04x", s.value)+                data.append(contentsOf: hex.utf8)+            default:+                // Non-ASCII and / remain unescaped+                var tempStr = String(scalar)+                tempStr.withUTF8 { buf in+                    data.append(contentsOf: buf)+                }+            }+        }+        data.append(UInt8(ascii: "\""))+    }++    mutating func writeUUID(_ value: UUID) {+        writeString(value.uuidString.lowercased())+    }++    mutating func writeDate(_ value: Date) {+        let ms = Int64((value.timeIntervalSince1970 * 1000).rounded())+        let seconds = ms / 1000+        let millis = abs(Int(ms % 1000))+        let date = Date(timeIntervalSince1970: TimeInterval(seconds))+        var calendar = Calendar(identifier: .iso8601)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)+        let formatted = String(+            format: "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",+            comps.year!, comps.month!, comps.day!,+            comps.hour!, comps.minute!, comps.second!, millis+        )+        writeString(formatted)+    }++    mutating func writeOptionalString(_ value: String?) {+        if let value { writeString(value) } else { writeNull() }+    }++    mutating func writeOptionalUUID(_ value: UUID?) {+        if let value { writeUUID(value) } else { writeNull() }+    }++    mutating func writeOptionalInt(_ value: Int?) {+        if let value { writeInt(value) } else { writeNull() }+    }++    /// Writes an object with keys sorted lexicographically.+    mutating func writeObject(_ pairs: [(String, (inout CanonicalJSONWriter) -> Void)]) {+        let sorted = pairs.sorted { $0.0 < $1.0 }+        data.append(UInt8(ascii: "{"))+        for (index, (key, writer)) in sorted.enumerated() {+            if index > 0 { data.append(UInt8(ascii: ",")) }+            writeString(key)+            data.append(UInt8(ascii: ":"))+            writer(&self)+        }+        data.append(UInt8(ascii: "}"))+    }++    /// Writes an array of elements.+    mutating func writeArray(_ elements: [(inout CanonicalJSONWriter) -> Void]) {+        data.append(UInt8(ascii: "["))+        for (index, writer) in elements.enumerated() {+            if index > 0 { data.append(UInt8(ascii: ",")) }+            writer(&self)+        }+        data.append(UInt8(ascii: "]"))+    }+}++// MARK: - Codec Implementation++extension BackupV1Codec {+    /// Internal encode implementation.+    internal static func encodeImpl(+        snapshot: LibraryBackupSnapshot,+        metadata: BackupMetadata+    ) throws -> Data {+        // 1. Produce canonical payload bytes+        var payloadWriter = CanonicalJSONWriter()+        writePayload(snapshot: snapshot, writer: &payloadWriter)+        let payloadBytes = payloadWriter.data++        // 2. Compute SHA-256 checksum over payload bytes+        let digest = SHA256.hash(data: payloadBytes)+        let checksum = digest.map { String(format: "%02x", $0) }.joined()++        // 3. Produce canonical header bytes+        var headerWriter = CanonicalJSONWriter()+        writeHeader(metadata: metadata, checksum: checksum, snapshot: snapshot, writer: &headerWriter)+        let headerBytes = headerWriter.data++        // 4. Assemble envelope: {"header":<header>,"payload":<payload>}+        var envelope = Data()+        envelope.append(contentsOf: "{\"header\":".utf8)+        envelope.append(headerBytes)+        envelope.append(contentsOf: ",\"payload\":".utf8)+        envelope.append(payloadBytes)+        envelope.append(UInt8(ascii: "}"))+        return envelope+    }++    private static func writeHeader(+        metadata: BackupMetadata,+        checksum: String,+        snapshot: LibraryBackupSnapshot,+        writer: inout CanonicalJSONWriter+    ) {+        writer.writeObject([+            ("appBuild", { w in w.writeString(metadata.appBuild) }),+            ("backupFormatVersion", { w in w.writeInt(1) }),+            ("checksum", { w in w.writeString(checksum) }),+            ("databaseSchemaVersion", { w in w.writeInt(metadata.databaseSchemaVersion) }),+            ("entryCount", { w in w.writeInt(snapshot.entries.count) }),+            ("exportedAt", { w in w.writeDate(metadata.exportedAt) }),+            ("workCount", { w in w.writeInt(snapshot.works.count) }),+        ])+    }++    private static func writePayload(snapshot: LibraryBackupSnapshot, writer: inout CanonicalJSONWriter) {+        // Sort entries by lowercase UUID string+        let sortedEntries = snapshot.entries.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }+        // Sort works by lowercase UUID string+        let sortedWorks = snapshot.works.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }+        // Sort sites by hostname+        let sortedSites = snapshot.sites.sorted { $0.hostname < $1.hostname }+        // Sort title patterns by lowercase UUID string+        let sortedPatterns = snapshot.titlePatterns.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }++        writer.writeObject([+            ("entries", { w in w.writeArray(sortedEntries.map { entry in { (w2: inout CanonicalJSONWriter) in writeEntry(entry, writer: &w2) } }) }),+            ("sites", { w in w.writeArray(sortedSites.map { site in { (w2: inout CanonicalJSONWriter) in writeSite(site, writer: &w2) } }) }),+            ("titlePatterns", { w in w.writeArray(sortedPatterns.map { p in { (w2: inout CanonicalJSONWriter) in writeTitlePattern(p, writer: &w2) } }) }),+            ("works", { w in w.writeArray(sortedWorks.map { work in { (w2: inout CanonicalJSONWriter) in writeWork(work, writer: &w2) } }) }),+        ])+    }++    private static func writeEntry(_ entry: EntryRecord, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("canonicalURL", { w in w.writeOptionalString(entry.canonicalURL) }),+            ("captureTitle", { w in w.writeString(entry.captureTitle) }),+            ("captureTitleSource", { w in w.writeString(entry.captureTitleSource.rawValue) }),+            ("chapterTitle", { w in w.writeOptionalString(entry.chapterTitle) }),+            ("chapterTitleProvenance", { w in writeProvenance(entry.chapterTitleProvenance, writer: &w) }),+            ("entryIdentityKey", { w in w.writeString(entry.entryIdentityKey) }),+            ("firstCapturedAt", { w in w.writeDate(entry.firstCapturedAt) }),+            ("hostname", { w in w.writeString(entry.hostname) }),+            ("id", { w in w.writeUUID(entry.id) }),+            ("identityKeyVersion", { w in w.writeInt(entry.identityKeyVersion) }),+            ("intentionallyUnattached", { w in w.writeBool(entry.intentionallyUnattached) }),+            ("lastSharedAt", { w in w.writeDate(entry.lastSharedAt) }),+            ("modifiedAt", { w in w.writeDate(entry.modifiedAt) }),+            ("note", { w in w.writeString(entry.note) }),+            ("rating", { w in if let r = entry.rating { w.writeString(r.rawValue) } else { w.writeNull() } }),+            ("rawURL", { w in w.writeString(entry.rawURL) }),+            ("workAssignmentProvenance", { w in writeProvenance(entry.workAssignmentProvenance, writer: &w) }),+            ("workID", { w in w.writeOptionalUUID(entry.workID) }),+        ])+    }++    private static func writeWork(_ work: WorkRecord, writer: inout CanonicalJSONWriter) {+        // entryIDs sorted by lowercase UUID string+        let sortedEntryIDs = work.entryIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        writer.writeObject([+            ("createdAt", { w in w.writeDate(work.createdAt) }),+            ("displayTitle", { w in w.writeString(work.displayTitle) }),+            ("entryIDs", { w in w.writeArray(sortedEntryIDs.map { id in { (w2: inout CanonicalJSONWriter) in w2.writeUUID(id) } }) }),+            ("genericNotes", { w in w.writeString(work.genericNotes) }),+            ("genreTags", { w in w.writeArray(work.genreTags.map { tag in { (w2: inout CanonicalJSONWriter) in w2.writeString(tag) } }) }),+            ("id", { w in w.writeUUID(work.id) }),+            ("lastParsedTitle", { w in w.writeOptionalString(work.lastParsedTitle) }),+            ("modifiedAt", { w in w.writeDate(work.modifiedAt) }),+            ("siteHostname", { w in w.writeString(work.siteHostname) }),+            ("titleProvenance", { w in w.writeString(work.titleProvenance.rawValue) }),+            ("type", { w in w.writeString(work.type.rawValue) }),+            ("urlIdentity", { w in w.writeOptionalString(work.urlIdentity) }),+            ("workURL", { w in w.writeOptionalString(work.workURL) }),+        ])+    }++    private static func writeSite(_ site: SiteRecord, writer: inout CanonicalJSONWriter) {+        // patternIDs sorted by lowercase UUID string+        let sortedPatternIDs = site.patternIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        writer.writeObject([+            ("displayName", { w in w.writeString(site.displayName) }),+            ("hostname", { w in w.writeString(site.hostname) }),+            ("junkSuffixRule", { w in+                if let rule = site.junkSuffixRule { writeJunkSuffixRule(rule, writer: &w) }+                else { w.writeNull() }+            }),+            ("mode", { w in w.writeString(site.mode.rawValue) }),+            ("patternIDs", { w in w.writeArray(sortedPatternIDs.map { id in { (w2: inout CanonicalJSONWriter) in w2.writeUUID(id) } }) }),+            ("urlIdentityRule", { w in+                if let rule = site.urlIdentityRule { writeURLIdentityRule(rule, writer: &w) }+                else { w.writeNull() }+            }),+        ])+    }++    private static func writeTitlePattern(_ pattern: TitlePatternRecord, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("createdAt", { w in w.writeDate(pattern.createdAt) }),+            ("id", { w in w.writeUUID(pattern.id) }),+            ("isActive", { w in w.writeBool(pattern.isActive) }),+            ("junkAnchors", { w in w.writeArray(pattern.junkAnchors.map { a in { (w2: inout CanonicalJSONWriter) in writePosition(a, writer: &w2) } }) }),+            ("siteHostname", { w in w.writeString(pattern.siteHostname) }),+            ("version", { w in w.writeInt(pattern.version) }),+            ("workAnchor", { w in writeRange(pattern.workAnchor, writer: &w) }),+        ])+    }++    private static func writeProvenance(_ prov: FieldProvenance, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("kind", { w in w.writeString(prov.kind.rawValue) }),+            ("patternID", { w in w.writeOptionalUUID(prov.patternID) }),+            ("patternVersion", { w in w.writeOptionalInt(prov.patternVersion) }),+        ])+    }++    private static func writeRange(_ range: SegmentRangeSpec, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("length", { w in w.writeInt(range.length) }),+            ("offset", { w in w.writeInt(range.offset) }),+            ("origin", { w in w.writeString(range.origin.rawValue) }),+        ])+    }++    private static func writePosition(_ pos: SegmentPositionSpec, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("offset", { w in w.writeInt(pos.offset) }),+            ("origin", { w in w.writeString(pos.origin.rawValue) }),+        ])+    }++    private static func writeURLIdentityRule(_ rule: URLIdentityRule, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("component", { w in w.writeString(rule.component.rawValue) }),+            ("offset", { w in w.writeOptionalInt(rule.offset) }),+            ("origin", { w in if let o = rule.origin { w.writeString(o.rawValue) } else { w.writeNull() } }),+            ("queryName", { w in w.writeOptionalString(rule.queryName) }),+            ("version", { w in w.writeInt(rule.version) }),+        ])+    }++    private static func writeJunkSuffixRule(_ rule: JunkSuffixRule, writer: inout CanonicalJSONWriter) {+        writer.writeObject([+            ("anchors", { w in w.writeArray(rule.anchors.map { a in { (w2: inout CanonicalJSONWriter) in writePosition(a, writer: &w2) } }) }),+            ("version", { w in w.writeInt(rule.version) }),+        ])+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swift Added +219 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swiftnew file mode 100644index 0000000..b7b5dc1--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swift@@ -0,0 +1,219 @@+import CryptoKit+import Foundation++// MARK: - Strict JSON Reader++/// A strict byte-level JSON reader that tracks payload ranges and rejects+/// unknown/duplicate keys per the V1 backup format contract.+internal struct StrictJSONReader {+    private let bytes: [UInt8]+    private(set) var position: Int = 0++    init(_ data: Data) {+        self.bytes = [UInt8](data)+    }++    var isAtEnd: Bool { position >= bytes.count }+    var currentByte: UInt8? { position < bytes.count ? bytes[position] : nil }++    mutating func expectEnd() throws {+        guard isAtEnd else {+            throw BackupCodecError.trailingBytes+        }+    }++    mutating func skipByte() { position += 1 }++    mutating func expect(_ byte: UInt8) throws {+        guard position < bytes.count, bytes[position] == byte else {+            throw BackupCodecError.decodingFailed(reason: "expected '\(Character(UnicodeScalar(byte)))' at position \(position)")+        }+        position += 1+    }++    mutating func readString() throws -> String {+        try expect(UInt8(ascii: "\""))+        var result: [UInt8] = []+        while position < bytes.count {+            let b = bytes[position]+            if b == UInt8(ascii: "\"") {+                position += 1+                guard let s = String(bytes: result, encoding: .utf8) else {+                    throw BackupCodecError.decodingFailed(reason: "invalid UTF-8 in string")+                }+                return s+            }+            if b == UInt8(ascii: "\\") {+                position += 1+                guard position < bytes.count else {+                    throw BackupCodecError.decodingFailed(reason: "unterminated escape")+                }+                let escaped = bytes[position]+                position += 1+                switch escaped {+                case UInt8(ascii: "\""): result.append(UInt8(ascii: "\""))+                case UInt8(ascii: "\\"): result.append(UInt8(ascii: "\\"))+                case UInt8(ascii: "/"): result.append(UInt8(ascii: "/"))+                case UInt8(ascii: "b"): result.append(0x08)+                case UInt8(ascii: "f"): result.append(0x0C)+                case UInt8(ascii: "n"): result.append(0x0A)+                case UInt8(ascii: "r"): result.append(0x0D)+                case UInt8(ascii: "t"): result.append(0x09)+                case UInt8(ascii: "u"):+                    guard position + 4 <= bytes.count else {+                        throw BackupCodecError.decodingFailed(reason: "incomplete unicode escape")+                    }+                    let hex = String(bytes: bytes[position..<position+4], encoding: .ascii)!+                    position += 4+                    guard let codePoint = UInt32(hex, radix: 16),+                          let scalar = Unicode.Scalar(codePoint) else {+                        throw BackupCodecError.decodingFailed(reason: "invalid unicode escape \\u\(hex)")+                    }+                    var buf = [UInt8]()+                    for byte in scalar.utf8 { buf.append(byte) }+                    result.append(contentsOf: buf)+                default:+                    throw BackupCodecError.decodingFailed(reason: "invalid escape sequence \\")+                }+            } else {+                result.append(b)+                position += 1+            }+        }+        throw BackupCodecError.decodingFailed(reason: "unterminated string")+    }++    mutating func readInt() throws -> Int {+        var numStr = ""+        if position < bytes.count && bytes[position] == UInt8(ascii: "-") {+            numStr.append("-")+            position += 1+        }+        guard position < bytes.count, bytes[position] >= UInt8(ascii: "0"), bytes[position] <= UInt8(ascii: "9") else {+            throw BackupCodecError.decodingFailed(reason: "expected digit at position \(position)")+        }+        while position < bytes.count, bytes[position] >= UInt8(ascii: "0"), bytes[position] <= UInt8(ascii: "9") {+            numStr.append(Character(UnicodeScalar(bytes[position])))+            position += 1+        }+        guard let value = Int(numStr) else {+            throw BackupCodecError.decodingFailed(reason: "invalid integer: \(numStr)")+        }+        return value+    }++    mutating func readBool() throws -> Bool {+        if bytes[position...].starts(with: "true".utf8) {+            position += 4; return true+        }+        if bytes[position...].starts(with: "false".utf8) {+            position += 5; return false+        }+        throw BackupCodecError.decodingFailed(reason: "expected boolean at position \(position)")+    }++    mutating func readNull() throws {+        guard bytes[position...].starts(with: "null".utf8) else {+            throw BackupCodecError.decodingFailed(reason: "expected null at position \(position)")+        }+        position += 4+    }++    mutating func peekIsNull() -> Bool {+        position < bytes.count - 3 && bytes[position...].starts(with: "null".utf8)+    }++    mutating func readOptionalString() throws -> String? {+        if peekIsNull() { try readNull(); return nil }+        return try readString()+    }++    mutating func readOptionalInt() throws -> Int? {+        if peekIsNull() { try readNull(); return nil }+        return try readInt()+    }++    mutating func readUUID() throws -> UUID {+        let str = try readString()+        guard let uuid = UUID(uuidString: str) else {+            throw BackupCodecError.decodingFailed(reason: "invalid UUID: \(str)")+        }+        return uuid+    }++    mutating func readOptionalUUID() throws -> UUID? {+        if peekIsNull() { try readNull(); return nil }+        return try readUUID()+    }++    mutating func readDate() throws -> Date {+        let str = try readString()+        let formatter = ISO8601DateFormatter()+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+        guard let date = formatter.date(from: str) else {+            throw BackupCodecError.invalidValue(key: "date", reason: "invalid RFC 3339 date: \(str)")+        }+        return date+    }++    /// Reads an object and returns keys in order encountered. Calls handler for each key.+    mutating func readObject(_ handler: (inout StrictJSONReader, String) throws -> Void) throws {+        try expect(UInt8(ascii: "{"))+        var keys: Set<String> = []+        var first = true+        while true {+            guard let next = currentByte else {+                throw BackupCodecError.decodingFailed(reason: "unterminated object")+            }+            if next == UInt8(ascii: "}") { position += 1; return }+            if !first { try expect(UInt8(ascii: ",")) }+            first = false+            let key = try readString()+            guard keys.insert(key).inserted else {+                throw BackupCodecError.duplicateKey(key)+            }+            try expect(UInt8(ascii: ":"))+            try handler(&self, key)+        }+    }++    /// Reads an array, calling handler for each element.+    mutating func readArray(_ handler: (inout StrictJSONReader) throws -> Void) throws {+        try expect(UInt8(ascii: "["))+        var first = true+        while true {+            guard let next = currentByte else {+                throw BackupCodecError.decodingFailed(reason: "unterminated array")+            }+            if next == UInt8(ascii: "]") { position += 1; return }+            if !first { try expect(UInt8(ascii: ",")) }+            first = false+            try handler(&self)+        }+    }++    /// Skips a JSON value (string, number, bool, null, object, array).+    mutating func skipValue() throws {+        guard let b = currentByte else {+            throw BackupCodecError.decodingFailed(reason: "unexpected end")+        }+        switch b {+        case UInt8(ascii: "\""): _ = try readString()+        case UInt8(ascii: "t"), UInt8(ascii: "f"): _ = try readBool()+        case UInt8(ascii: "n"): try readNull()+        case UInt8(ascii: "{"):+            try readObject { reader, _ in try reader.skipValue() }+        case UInt8(ascii: "["):+            try readArray { reader in try reader.skipValue() }+        default: _ = try readInt()+        }+    }++    /// Records the byte range of the next JSON value without consuming it in a way+    /// that changes the read result. Returns (startPosition, endPosition).+    mutating func recordValueRange() throws -> Range<Int> {+        let start = position+        try skipValue()+        return start..<position+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swift Added +146 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swiftnew file mode 100644index 0000000..d080da0--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swift@@ -0,0 +1,146 @@+import CryptoKit+import Foundation++// MARK: - Decode Implementation++extension BackupV1Codec {+    internal static func decodeImpl(_ data: Data) throws -> DecodedBackup {+        var reader = StrictJSONReader(data)+        var header: BackupHeader?+        var snapshot: LibraryBackupSnapshot?+        var payloadRange: Range<Int>?++        let validEnvelopeKeys: Set<String> = ["header", "payload"]+        var seenKeys: Set<String> = []++        try reader.readObject { reader, key in+            guard validEnvelopeKeys.contains(key) else {+                throw BackupCodecError.unknownKey(key)+            }+            guard seenKeys.insert(key).inserted else {+                throw BackupCodecError.duplicateKey(key)+            }+            switch key {+            case "header":+                header = try readHeader(&reader)+            case "payload":+                let start = reader.position+                snapshot = try readPayload(&reader)+                payloadRange = start..<reader.position+            default:+                break+            }+        }++        try reader.expectEnd()++        guard let h = header else { throw BackupCodecError.missingKey("header") }+        guard let s = snapshot else { throw BackupCodecError.missingKey("payload") }+        guard let pr = payloadRange else { throw BackupCodecError.missingKey("payload") }++        guard h.backupFormatVersion == 1 else {+            throw BackupCodecError.invalidFormatVersion(h.backupFormatVersion)+        }+        guard h.databaseSchemaVersion == 1 else {+            throw BackupCodecError.invalidSchemaVersion(h.databaseSchemaVersion)+        }++        return DecodedBackup(snapshot: s, header: h, payloadRange: pr)+    }++    private static func readHeader(_ reader: inout StrictJSONReader) throws -> BackupHeader {+        let validKeys: Set<String> = [+            "appBuild", "backupFormatVersion", "checksum",+            "databaseSchemaVersion", "entryCount", "exportedAt", "workCount",+        ]+        var appBuild: String?+        var formatVersion: Int?+        var checksum: String?+        var schemaVersion: Int?+        var entryCount: Int?+        var exportedAt: Date?+        var workCount: Int?++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else {+                throw BackupCodecError.unknownKey(key)+            }+            switch key {+            case "appBuild": appBuild = try reader.readString()+            case "backupFormatVersion": formatVersion = try reader.readInt()+            case "checksum": checksum = try reader.readString()+            case "databaseSchemaVersion": schemaVersion = try reader.readInt()+            case "entryCount": entryCount = try reader.readInt()+            case "exportedAt": exportedAt = try reader.readDate()+            case "workCount": workCount = try reader.readInt()+            default: break+            }+        }++        guard let ab = appBuild else { throw BackupCodecError.missingKey("appBuild") }+        guard let fv = formatVersion else { throw BackupCodecError.missingKey("backupFormatVersion") }+        guard let cs = checksum else { throw BackupCodecError.missingKey("checksum") }+        guard let sv = schemaVersion else { throw BackupCodecError.missingKey("databaseSchemaVersion") }+        guard let ec = entryCount else { throw BackupCodecError.missingKey("entryCount") }+        guard let ea = exportedAt else { throw BackupCodecError.missingKey("exportedAt") }+        guard let wc = workCount else { throw BackupCodecError.missingKey("workCount") }++        return BackupHeader(+            backupFormatVersion: fv,+            databaseSchemaVersion: sv,+            appBuild: ab,+            exportedAt: ea,+            entryCount: ec,+            workCount: wc,+            checksum: cs+        )+    }++    private static func readPayload(_ reader: inout StrictJSONReader) throws -> LibraryBackupSnapshot {+        let validKeys: Set<String> = ["entries", "sites", "titlePatterns", "works"]+        var entries: [EntryRecord]?+        var works: [WorkRecord]?+        var sites: [SiteRecord]?+        var titlePatterns: [TitlePatternRecord]?++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else {+                throw BackupCodecError.unknownKey(key)+            }+            switch key {+            case "entries":+                var items: [EntryRecord] = []+                try reader.readArray { reader in+                    items.append(try readEntry(&reader))+                }+                entries = items+            case "works":+                var items: [WorkRecord] = []+                try reader.readArray { reader in+                    items.append(try readWork(&reader))+                }+                works = items+            case "sites":+                var items: [SiteRecord] = []+                try reader.readArray { reader in+                    items.append(try readSite(&reader))+                }+                sites = items+            case "titlePatterns":+                var items: [TitlePatternRecord] = []+                try reader.readArray { reader in+                    items.append(try readTitlePattern(&reader))+                }+                titlePatterns = items+            default: break+            }+        }++        guard let e = entries else { throw BackupCodecError.missingKey("entries") }+        guard let w = works else { throw BackupCodecError.missingKey("works") }+        guard let s = sites else { throw BackupCodecError.missingKey("sites") }+        guard let tp = titlePatterns else { throw BackupCodecError.missingKey("titlePatterns") }++        return LibraryBackupSnapshot(entries: e, works: w, sites: s, titlePatterns: tp)+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swift Added +336 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swiftnew file mode 100644index 0000000..7e249db--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swift@@ -0,0 +1,336 @@+import Foundation++// MARK: - Record-Level Decoding++extension BackupV1Codec {+    static func readEntry(_ reader: inout StrictJSONReader) throws -> EntryRecord {+        let validKeys: Set<String> = [+            "canonicalURL", "captureTitle", "captureTitleSource", "chapterTitle",+            "chapterTitleProvenance", "entryIdentityKey", "firstCapturedAt",+            "hostname", "id", "identityKeyVersion", "intentionallyUnattached",+            "lastSharedAt", "modifiedAt", "note", "rating", "rawURL",+            "workAssignmentProvenance", "workID",+        ]+        var id: UUID?; var captureTitle: String?; var captureTitleSource: String?+        var rawURL: String?; var canonicalURL: String??; var hostname: String?+        var entryIdentityKey: String?; var identityKeyVersion: Int?+        var chapterTitle: String??; var chapterTitleProvenance: FieldProvenance?+        var note: String?; var rating: String??+        var firstCapturedAt: Date?; var lastSharedAt: Date?; var modifiedAt: Date?+        var workID: UUID??; var workAssignmentProvenance: FieldProvenance?+        var intentionallyUnattached: Bool?+        var seenKeys: Set<String> = []++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }+            guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }+            switch key {+            case "id": id = try reader.readUUID()+            case "captureTitle": captureTitle = try reader.readString()+            case "captureTitleSource": captureTitleSource = try reader.readString()+            case "rawURL": rawURL = try reader.readString()+            case "canonicalURL": canonicalURL = .some(try reader.readOptionalString())+            case "hostname": hostname = try reader.readString()+            case "entryIdentityKey": entryIdentityKey = try reader.readString()+            case "identityKeyVersion": identityKeyVersion = try reader.readInt()+            case "chapterTitle": chapterTitle = .some(try reader.readOptionalString())+            case "chapterTitleProvenance": chapterTitleProvenance = try readProvenance(&reader)+            case "note": note = try reader.readString()+            case "rating": rating = .some(try reader.readOptionalString())+            case "firstCapturedAt": firstCapturedAt = try reader.readDate()+            case "lastSharedAt": lastSharedAt = try reader.readDate()+            case "modifiedAt": modifiedAt = try reader.readDate()+            case "workID": workID = .some(try reader.readOptionalUUID())+            case "workAssignmentProvenance": workAssignmentProvenance = try readProvenance(&reader)+            case "intentionallyUnattached": intentionallyUnattached = try reader.readBool()+            default: break+            }+        }++        guard seenKeys.count == validKeys.count else {+            let missing = validKeys.subtracting(seenKeys)+            throw BackupCodecError.missingKey(missing.sorted().first!)+        }++        guard let titleSourceStr = captureTitleSource,+              let titleSource = CaptureTitleSource(rawValue: titleSourceStr) else {+            throw BackupCodecError.invalidValue(key: "captureTitleSource", reason: "invalid enum value")+        }++        let parsedRating: Rating?+        if case .some(let ratingStr) = rating {+            if let str = ratingStr {+                guard let r = Rating(rawValue: str) else {+                    throw BackupCodecError.invalidValue(key: "rating", reason: "invalid enum value: \(str)")+                }+                parsedRating = r+            } else { parsedRating = nil }+        } else { parsedRating = nil }++        return EntryRecord(+            id: id!, captureTitle: captureTitle!, captureTitleSource: titleSource,+            rawURL: rawURL!, canonicalURL: canonicalURL!,+            hostname: hostname!, entryIdentityKey: entryIdentityKey!,+            identityKeyVersion: identityKeyVersion!,+            chapterTitle: chapterTitle!,+            chapterTitleProvenance: chapterTitleProvenance!,+            note: note!, rating: parsedRating,+            firstCapturedAt: firstCapturedAt!, lastSharedAt: lastSharedAt!,+            modifiedAt: modifiedAt!, workID: workID!,+            workAssignmentProvenance: workAssignmentProvenance!,+            intentionallyUnattached: intentionallyUnattached!+        )+    }++    static func readWork(_ reader: inout StrictJSONReader) throws -> WorkRecord {+        let validKeys: Set<String> = [+            "createdAt", "displayTitle", "entryIDs", "genericNotes", "genreTags",+            "id", "lastParsedTitle", "modifiedAt", "siteHostname",+            "titleProvenance", "type", "urlIdentity", "workURL",+        ]+        var id: UUID?; var displayTitle: String?; var lastParsedTitle: String??+        var siteHostname: String?; var urlIdentity: String??; var workURL: String??+        var genericNotes: String?; var typeRaw: String?; var genreTags: [String]?+        var titleProvenance: String?; var createdAt: Date?; var modifiedAt: Date?+        var entryIDs: [UUID]?+        var seenKeys: Set<String> = []++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }+            guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }+            switch key {+            case "id": id = try reader.readUUID()+            case "displayTitle": displayTitle = try reader.readString()+            case "lastParsedTitle": lastParsedTitle = .some(try reader.readOptionalString())+            case "siteHostname": siteHostname = try reader.readString()+            case "urlIdentity": urlIdentity = .some(try reader.readOptionalString())+            case "workURL": workURL = .some(try reader.readOptionalString())+            case "genericNotes": genericNotes = try reader.readString()+            case "type": typeRaw = try reader.readString()+            case "genreTags":+                var tags: [String] = []+                try reader.readArray { r in tags.append(try r.readString()) }+                genreTags = tags+            case "titleProvenance": titleProvenance = try reader.readString()+            case "createdAt": createdAt = try reader.readDate()+            case "modifiedAt": modifiedAt = try reader.readDate()+            case "entryIDs":+                var ids: [UUID] = []+                try reader.readArray { r in ids.append(try r.readUUID()) }+                entryIDs = ids+            default: break+            }+        }++        guard seenKeys.count == validKeys.count else {+            let missing = validKeys.subtracting(seenKeys)+            throw BackupCodecError.missingKey(missing.sorted().first!)+        }+        guard let t = typeRaw, let workType = WorkType(rawValue: t) else {+            throw BackupCodecError.invalidValue(key: "type", reason: "invalid work type")+        }+        guard let tp = titleProvenance, let prov = TitleProvenance(rawValue: tp) else {+            throw BackupCodecError.invalidValue(key: "titleProvenance", reason: "invalid title provenance")+        }++        return WorkRecord(+            id: id!, displayTitle: displayTitle!, lastParsedTitle: lastParsedTitle!,+            siteHostname: siteHostname!, urlIdentity: urlIdentity!, workURL: workURL!,+            genericNotes: genericNotes!, type: workType, genreTags: genreTags!,+            titleProvenance: prov, createdAt: createdAt!, modifiedAt: modifiedAt!,+            entryIDs: entryIDs!+        )+    }++    static func readSite(_ reader: inout StrictJSONReader) throws -> SiteRecord {+        let validKeys: Set<String> = [+            "displayName", "hostname", "junkSuffixRule", "mode", "patternIDs", "urlIdentityRule",+        ]+        var hostname: String?; var displayName: String?; var modeRaw: String?+        var patternIDs: [UUID]?; var urlIdentityRule: URLIdentityRule??+        var junkSuffixRule: JunkSuffixRule??+        var seenKeys: Set<String> = []++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }+            guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }+            switch key {+            case "hostname": hostname = try reader.readString()+            case "displayName": displayName = try reader.readString()+            case "mode": modeRaw = try reader.readString()+            case "patternIDs":+                var ids: [UUID] = []+                try reader.readArray { r in ids.append(try r.readUUID()) }+                patternIDs = ids+            case "urlIdentityRule":+                if reader.peekIsNull() { try reader.readNull(); urlIdentityRule = .some(nil) }+                else { urlIdentityRule = .some(try readURLIdentityRule(&reader)) }+            case "junkSuffixRule":+                if reader.peekIsNull() { try reader.readNull(); junkSuffixRule = .some(nil) }+                else { junkSuffixRule = .some(try readJunkSuffixRule(&reader)) }+            default: break+            }+        }++        guard seenKeys.count == validKeys.count else {+            let missing = validKeys.subtracting(seenKeys)+            throw BackupCodecError.missingKey(missing.sorted().first!)+        }+        guard let m = modeRaw, let mode = SiteMode(rawValue: m) else {+            throw BackupCodecError.invalidValue(key: "mode", reason: "invalid site mode")+        }++        return SiteRecord(+            hostname: hostname!, displayName: displayName!, mode: mode,+            patternIDs: patternIDs!, urlIdentityRule: urlIdentityRule!,+            junkSuffixRule: junkSuffixRule!+        )+    }++    static func readTitlePattern(_ reader: inout StrictJSONReader) throws -> TitlePatternRecord {+        let validKeys: Set<String> = [+            "createdAt", "id", "isActive", "junkAnchors", "siteHostname", "version", "workAnchor",+        ]+        var id: UUID?; var version: Int?; var isActive: Bool?+        var createdAt: Date?; var workAnchor: SegmentRangeSpec?+        var junkAnchors: [SegmentPositionSpec]?; var siteHostname: String?+        var seenKeys: Set<String> = []++        try reader.readObject { reader, key in+            guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }+            guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }+            switch key {+            case "id": id = try reader.readUUID()+            case "version": version = try reader.readInt()+            case "isActive": isActive = try reader.readBool()+            case "createdAt": createdAt = try reader.readDate()+            case "workAnchor": workAnchor = try readRange(&reader)+            case "junkAnchors":+                var anchors: [SegmentPositionSpec] = []+                try reader.readArray { r in anchors.append(try readPosition(&r)) }+                junkAnchors = anchors+            case "siteHostname": siteHostname = try reader.readString()+            default: break+            }+        }++        guard seenKeys.count == validKeys.count else {+            let missing = validKeys.subtracting(seenKeys)+            throw BackupCodecError.missingKey(missing.sorted().first!)+        }++        return TitlePatternRecord(+            id: id!, version: version!, isActive: isActive!,+            createdAt: createdAt!, workAnchor: workAnchor!,+            junkAnchors: junkAnchors!, siteHostname: siteHostname!+        )+    }++    // MARK: - Value Object Decoding++    static func readProvenance(_ reader: inout StrictJSONReader) throws -> FieldProvenance {+        var kind: String?; var patternID: UUID??; var patternVersion: Int??+        try reader.readObject { reader, key in+            switch key {+            case "kind": kind = try reader.readString()+            case "patternID": patternID = .some(try reader.readOptionalUUID())+            case "patternVersion": patternVersion = .some(try reader.readOptionalInt())+            default: throw BackupCodecError.unknownKey(key)+            }+        }+        guard let k = kind, let provKind = FieldProvenanceKind(rawValue: k) else {+            throw BackupCodecError.invalidValue(key: "kind", reason: "invalid provenance kind")+        }+        guard patternID != nil, patternVersion != nil else {+            throw BackupCodecError.missingKey("patternID or patternVersion")+        }+        return try FieldProvenance(kind: provKind, patternID: patternID!, patternVersion: patternVersion!)+    }++    static func readRange(_ reader: inout StrictJSONReader) throws -> SegmentRangeSpec {+        var origin: String?; var offset: Int?; var length: Int?+        try reader.readObject { reader, key in+            switch key {+            case "origin": origin = try reader.readString()+            case "offset": offset = try reader.readInt()+            case "length": length = try reader.readInt()+            default: throw BackupCodecError.unknownKey(key)+            }+        }+        guard let o = origin, let ao = AnchorOrigin(rawValue: o) else {+            throw BackupCodecError.invalidValue(key: "origin", reason: "invalid anchor origin")+        }+        guard let off = offset, let len = length else {+            throw BackupCodecError.missingKey("offset or length")+        }+        return try SegmentRangeSpec(origin: ao, offset: off, length: len)+    }++    static func readPosition(_ reader: inout StrictJSONReader) throws -> SegmentPositionSpec {+        var origin: String?; var offset: Int?+        try reader.readObject { reader, key in+            switch key {+            case "origin": origin = try reader.readString()+            case "offset": offset = try reader.readInt()+            default: throw BackupCodecError.unknownKey(key)+            }+        }+        guard let o = origin, let ao = AnchorOrigin(rawValue: o) else {+            throw BackupCodecError.invalidValue(key: "origin", reason: "invalid anchor origin")+        }+        guard let off = offset else {+            throw BackupCodecError.missingKey("offset")+        }+        return try SegmentPositionSpec(origin: ao, offset: off)+    }++    static func readURLIdentityRule(_ reader: inout StrictJSONReader) throws -> URLIdentityRule {+        var component: String?; var offset: Int??; var origin: String??+        var queryName: String??; var version: Int?+        try reader.readObject { reader, key in+            switch key {+            case "component": component = try reader.readString()+            case "offset": offset = .some(try reader.readOptionalInt())+            case "origin": origin = .some(try reader.readOptionalString())+            case "queryName": queryName = .some(try reader.readOptionalString())+            case "version": version = try reader.readInt()+            default: throw BackupCodecError.unknownKey(key)+            }+        }+        guard let c = component, let comp = URLRuleComponent(rawValue: c) else {+            throw BackupCodecError.invalidValue(key: "component", reason: "invalid URL rule component")+        }+        guard let v = version else { throw BackupCodecError.missingKey("version") }+        switch comp {+        case .pathSegment:+            guard let originStr = origin!, let ao = AnchorOrigin(rawValue: originStr),+                  let off = offset! else {+                throw BackupCodecError.invalidValue(key: "urlIdentityRule", reason: "pathSegment requires origin and offset")+            }+            return try URLIdentityRule(version: v, component: comp, origin: ao, offset: off)+        case .queryItem:+            guard let qn = queryName! else {+                throw BackupCodecError.invalidValue(key: "urlIdentityRule", reason: "queryItem requires queryName")+            }+            return try URLIdentityRule(version: v, component: comp, queryName: qn)+        }+    }++    static func readJunkSuffixRule(_ reader: inout StrictJSONReader) throws -> JunkSuffixRule {+        var version: Int?; var anchors: [SegmentPositionSpec]?+        try reader.readObject { reader, key in+            switch key {+            case "version": version = try reader.readInt()+            case "anchors":+                var items: [SegmentPositionSpec] = []+                try reader.readArray { r in items.append(try readPosition(&r)) }+                anchors = items+            default: throw BackupCodecError.unknownKey(key)+            }+        }+        guard let v = version, let a = anchors else {+            throw BackupCodecError.missingKey("version or anchors")+        }+        return try JunkSuffixRule(version: v, anchors: a)+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1Types.swift Added +357 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Types.swiftnew file mode 100644index 0000000..e228e7a--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Types.swift@@ -0,0 +1,357 @@+import Foundation++// MARK: - Backup Record Types++/// Typed backup record for an Entry, matching the V1 canonical JSON contract.+public struct EntryRecord: Equatable, Sendable {+    public let id: UUID+    public let captureTitle: String+    public let captureTitleSource: CaptureTitleSource+    public let rawURL: String+    public let canonicalURL: String?+    public let hostname: String+    public let entryIdentityKey: String+    public let identityKeyVersion: Int+    public let chapterTitle: String?+    public let chapterTitleProvenance: FieldProvenance+    public let note: String+    public let rating: Rating?+    public let firstCapturedAt: Date+    public let lastSharedAt: Date+    public let modifiedAt: Date+    public let workID: UUID?+    public let workAssignmentProvenance: FieldProvenance+    public let intentionallyUnattached: Bool++    public init(+        id: UUID,+        captureTitle: String,+        captureTitleSource: CaptureTitleSource,+        rawURL: String,+        canonicalURL: String?,+        hostname: String,+        entryIdentityKey: String,+        identityKeyVersion: Int,+        chapterTitle: String?,+        chapterTitleProvenance: FieldProvenance,+        note: String,+        rating: Rating?,+        firstCapturedAt: Date,+        lastSharedAt: Date,+        modifiedAt: Date,+        workID: UUID?,+        workAssignmentProvenance: FieldProvenance,+        intentionallyUnattached: Bool+    ) {+        self.id = id+        self.captureTitle = captureTitle+        self.captureTitleSource = captureTitleSource+        self.rawURL = rawURL+        self.canonicalURL = canonicalURL+        self.hostname = hostname+        self.entryIdentityKey = entryIdentityKey+        self.identityKeyVersion = identityKeyVersion+        self.chapterTitle = chapterTitle+        self.chapterTitleProvenance = chapterTitleProvenance+        self.note = note+        self.rating = rating+        self.firstCapturedAt = firstCapturedAt+        self.lastSharedAt = lastSharedAt+        self.modifiedAt = modifiedAt+        self.workID = workID+        self.workAssignmentProvenance = workAssignmentProvenance+        self.intentionallyUnattached = intentionallyUnattached+    }+}++/// Typed backup record for a Work, matching the V1 canonical JSON contract.+public struct WorkRecord: Equatable, Sendable {+    public let id: UUID+    public let displayTitle: String+    public let lastParsedTitle: String?+    public let siteHostname: String+    public let urlIdentity: String?+    public let workURL: String?+    public let genericNotes: String+    public let type: WorkType+    public let genreTags: [String]+    public let titleProvenance: TitleProvenance+    public let createdAt: Date+    public let modifiedAt: Date+    public let entryIDs: [UUID]++    public init(+        id: UUID,+        displayTitle: String,+        lastParsedTitle: String?,+        siteHostname: String,+        urlIdentity: String?,+        workURL: String?,+        genericNotes: String,+        type: WorkType,+        genreTags: [String],+        titleProvenance: TitleProvenance,+        createdAt: Date,+        modifiedAt: Date,+        entryIDs: [UUID]+    ) {+        self.id = id+        self.displayTitle = displayTitle+        self.lastParsedTitle = lastParsedTitle+        self.siteHostname = siteHostname+        self.urlIdentity = urlIdentity+        self.workURL = workURL+        self.genericNotes = genericNotes+        self.type = type+        self.genreTags = genreTags+        self.titleProvenance = titleProvenance+        self.createdAt = createdAt+        self.modifiedAt = modifiedAt+        self.entryIDs = entryIDs+    }+}++/// Typed backup record for a Site, matching the V1 canonical JSON contract.+public struct SiteRecord: Equatable, Sendable {+    public let hostname: String+    public let displayName: String+    public let mode: SiteMode+    public let patternIDs: [UUID]+    public let urlIdentityRule: URLIdentityRule?+    public let junkSuffixRule: JunkSuffixRule?++    public init(+        hostname: String,+        displayName: String,+        mode: SiteMode,+        patternIDs: [UUID],+        urlIdentityRule: URLIdentityRule?,+        junkSuffixRule: JunkSuffixRule?+    ) {+        self.hostname = hostname+        self.displayName = displayName+        self.mode = mode+        self.patternIDs = patternIDs+        self.urlIdentityRule = urlIdentityRule+        self.junkSuffixRule = junkSuffixRule+    }+}++/// Typed backup record for a TitlePattern, matching the V1 canonical JSON contract.+public struct TitlePatternRecord: Equatable, Sendable {+    public let id: UUID+    public let version: Int+    public let isActive: Bool+    public let createdAt: Date+    public let workAnchor: SegmentRangeSpec+    public let junkAnchors: [SegmentPositionSpec]+    public let siteHostname: String++    public init(+        id: UUID,+        version: Int,+        isActive: Bool,+        createdAt: Date,+        workAnchor: SegmentRangeSpec,+        junkAnchors: [SegmentPositionSpec],+        siteHostname: String+    ) {+        self.id = id+        self.version = version+        self.isActive = isActive+        self.createdAt = createdAt+        self.workAnchor = workAnchor+        self.junkAnchors = junkAnchors+        self.siteHostname = siteHostname+    }+}++// MARK: - Backup Snapshot++/// Immutable point-in-time library snapshot used for backup export and validation.+public struct LibraryBackupSnapshot: Equatable, Sendable {+    public let entries: [EntryRecord]+    public let works: [WorkRecord]+    public let sites: [SiteRecord]+    public let titlePatterns: [TitlePatternRecord]++    public init(+        entries: [EntryRecord],+        works: [WorkRecord],+        sites: [SiteRecord],+        titlePatterns: [TitlePatternRecord]+    ) {+        self.entries = entries+        self.works = works+        self.sites = sites+        self.titlePatterns = titlePatterns+    }++    public static let empty = LibraryBackupSnapshot(entries: [], works: [], sites: [], titlePatterns: [])+}++// MARK: - Backup Metadata++/// Injected metadata for the backup envelope header, supplied by the containing app.+public struct BackupMetadata: Equatable, Sendable {+    public let appBuild: String+    public let databaseSchemaVersion: Int+    public let exportedAt: Date++    public init(appBuild: String, databaseSchemaVersion: Int, exportedAt: Date) {+        self.appBuild = appBuild+        self.databaseSchemaVersion = databaseSchemaVersion+        self.exportedAt = exportedAt+    }+}++// MARK: - Codec Error++/// Errors produced by the canonical backup codec during encoding or decoding.+public enum BackupCodecError: Error, Equatable, Sendable, CustomStringConvertible {+    case encodingFailed(reason: String)+    case decodingFailed(reason: String)+    case checksumMismatch(expected: String, actual: String)+    case invalidFormatVersion(Int)+    case invalidSchemaVersion(Int)+    case unknownKey(String)+    case duplicateKey(String)+    case missingKey(String)+    case invalidValue(key: String, reason: String)+    case trailingBytes+    case invalidEnvelopeStructure(reason: String)++    public var description: String {+        switch self {+        case .encodingFailed(let reason): "Backup encoding failed: \(reason)"+        case .decodingFailed(let reason): "Backup decoding failed: \(reason)"+        case .checksumMismatch(let expected, let actual): "Checksum mismatch: expected \(expected), got \(actual)"+        case .invalidFormatVersion(let v): "Unsupported backup format version: \(v)"+        case .invalidSchemaVersion(let v): "Unsupported database schema version: \(v)"+        case .unknownKey(let k): "Unknown key in backup: \(k)"+        case .duplicateKey(let k): "Duplicate key in backup: \(k)"+        case .missingKey(let k): "Missing required key: \(k)"+        case .invalidValue(let key, let reason): "Invalid value for \(key): \(reason)"+        case .trailingBytes: "Trailing bytes after backup envelope"+        case .invalidEnvelopeStructure(let reason): "Invalid backup envelope: \(reason)"+        }+    }+}++// MARK: - Validation Error++/// Errors produced during backup validation (post-decode integrity checks).+public enum BackupValidationError: Error, Equatable, Sendable, CustomStringConvertible {+    case checksumMismatch(expected: String, actual: String)+    case countMismatch(type: String, expected: Int, actual: Int)+    case duplicateID(type: String, id: UUID)+    case duplicateHostname(String)+    case unresolvedReference(type: String, id: UUID, referencedType: String, referencedID: UUID)+    case unresolvedHostname(type: String, id: String, hostname: String)+    case unresolvedProvenance(type: String, id: UUID, patternID: UUID, patternVersion: Int)+    case snapshotMismatch(reason: String)+    case invalidActivePatternCount(hostname: String, count: Int)++    public var description: String {+        switch self {+        case .checksumMismatch(let expected, let actual):+            "Payload checksum mismatch: expected \(expected), actual \(actual)"+        case .countMismatch(let type, let expected, let actual):+            "\(type) count mismatch: header declares \(expected), found \(actual)"+        case .duplicateID(let type, let id):+            "Duplicate \(type) ID: \(id)"+        case .duplicateHostname(let hostname):+            "Duplicate Site hostname: \(hostname)"+        case .unresolvedReference(let type, let id, let referencedType, let referencedID):+            "\(type) \(id) references missing \(referencedType) \(referencedID)"+        case .unresolvedHostname(let type, let id, let hostname):+            "\(type) \(id) references missing Site hostname \(hostname)"+        case .unresolvedProvenance(let type, let id, let patternID, let patternVersion):+            "\(type) \(id) references pattern \(patternID) version \(patternVersion) that does not exist"+        case .snapshotMismatch(let reason):+            "Decoded payload does not match source snapshot: \(reason)"+        case .invalidActivePatternCount(let hostname, let count):+            "Site \(hostname) has \(count) active patterns (expected at most 1)"+        }+    }+}++// MARK: - Codec Interface++/// Canonical Backup V1 codec responsible for encoding and strict decoding.+public enum BackupV1Codec {+    /// Encodes a backup snapshot into canonical JSON bytes with SHA-256 checksum.+    /// The metadata is injected by the containing app.+    public static func encode(+        snapshot: LibraryBackupSnapshot,+        metadata: BackupMetadata+    ) throws -> Data {+        try encodeImpl(snapshot: snapshot, metadata: metadata)+    }++    /// Strictly decodes a backup envelope from canonical JSON bytes.+    /// Returns the decoded snapshot and the payload byte range for checksum verification.+    public static func decode(+        _ data: Data+    ) throws -> DecodedBackup {+        try decodeImpl(data)+    }+}++/// Result of decoding a backup envelope, including the payload byte range+/// needed for checksum verification.+public struct DecodedBackup: Equatable, Sendable {+    public let snapshot: LibraryBackupSnapshot+    public let header: BackupHeader+    public let payloadRange: Range<Int>++    public init(snapshot: LibraryBackupSnapshot, header: BackupHeader, payloadRange: Range<Int>) {+        self.snapshot = snapshot+        self.header = header+        self.payloadRange = payloadRange+    }+}++/// Parsed backup header from the envelope.+public struct BackupHeader: Equatable, Sendable {+    public let backupFormatVersion: Int+    public let databaseSchemaVersion: Int+    public let appBuild: String+    public let exportedAt: Date+    public let entryCount: Int+    public let workCount: Int+    public let checksum: String++    public init(+        backupFormatVersion: Int,+        databaseSchemaVersion: Int,+        appBuild: String,+        exportedAt: Date,+        entryCount: Int,+        workCount: Int,+        checksum: String+    ) {+        self.backupFormatVersion = backupFormatVersion+        self.databaseSchemaVersion = databaseSchemaVersion+        self.appBuild = appBuild+        self.exportedAt = exportedAt+        self.entryCount = entryCount+        self.workCount = workCount+        self.checksum = checksum+    }+}++// MARK: - Backup Validator++/// Validates a decoded backup against its source snapshot and internal invariants.+public enum BackupV1Validator {+    /// Validates the decoded backup against the original source snapshot.+    /// Checks checksum, deep equality, inventory, references, counts, and uniqueness.+    public static func validate(+        decoded: DecodedBackup,+        source: LibraryBackupSnapshot,+        encodedData: Data+    ) throws {+        try validateImpl(decoded: decoded, source: source, encodedData: encodedData)+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swift Added +205 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swiftnew file mode 100644index 0000000..d8eddcb--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swift@@ -0,0 +1,205 @@+import CryptoKit+import Foundation++// MARK: - Validator Implementation++extension BackupV1Validator {+    internal static func validateImpl(+        decoded: DecodedBackup,+        source: LibraryBackupSnapshot,+        encodedData: Data+    ) throws {+        // 1. Checksum verification over exact payload bytes+        let payloadSlice = encodedData[decoded.payloadRange]+        let digest = SHA256.hash(data: payloadSlice)+        let computedChecksum = digest.map { String(format: "%02x", $0) }.joined()+        guard computedChecksum == decoded.header.checksum else {+            throw BackupValidationError.checksumMismatch(+                expected: decoded.header.checksum,+                actual: computedChecksum+            )+        }++        // 2. Header count validation+        guard decoded.header.entryCount == decoded.snapshot.entries.count else {+            throw BackupValidationError.countMismatch(+                type: "Entry",+                expected: decoded.header.entryCount,+                actual: decoded.snapshot.entries.count+            )+        }+        guard decoded.header.workCount == decoded.snapshot.works.count else {+            throw BackupValidationError.countMismatch(+                type: "Work",+                expected: decoded.header.workCount,+                actual: decoded.snapshot.works.count+            )+        }++        // 3. Deep equality with source snapshot+        guard decoded.snapshot == source else {+            throw BackupValidationError.snapshotMismatch(+                reason: "decoded payload does not match source snapshot"+            )+        }++        // 4. UUID uniqueness within each record type+        try validateUniqueEntryIDs(decoded.snapshot.entries)+        try validateUniqueWorkIDs(decoded.snapshot.works)+        try validateUniqueTitlePatternIDs(decoded.snapshot.titlePatterns)+        try validateUniqueSiteHostnames(decoded.snapshot.sites)++        // 5. All relationships resolve+        try validateEntryReferences(decoded.snapshot)+        try validateWorkReferences(decoded.snapshot)+        try validateSiteReferences(decoded.snapshot)+        try validateProvenanceReferences(decoded.snapshot)++        // 6. At most one active pattern per Site+        try validateActivePatternCounts(decoded.snapshot)+    }++    // MARK: - Uniqueness Checks++    private static func validateUniqueEntryIDs(_ entries: [EntryRecord]) throws {+        var seen: Set<UUID> = []+        for entry in entries {+            guard seen.insert(entry.id).inserted else {+                throw BackupValidationError.duplicateID(type: "Entry", id: entry.id)+            }+        }+    }++    private static func validateUniqueWorkIDs(_ works: [WorkRecord]) throws {+        var seen: Set<UUID> = []+        for work in works {+            guard seen.insert(work.id).inserted else {+                throw BackupValidationError.duplicateID(type: "Work", id: work.id)+            }+        }+    }++    private static func validateUniqueTitlePatternIDs(_ patterns: [TitlePatternRecord]) throws {+        var seen: Set<UUID> = []+        for pattern in patterns {+            guard seen.insert(pattern.id).inserted else {+                throw BackupValidationError.duplicateID(type: "TitlePattern", id: pattern.id)+            }+        }+    }++    private static func validateUniqueSiteHostnames(_ sites: [SiteRecord]) throws {+        var seen: Set<String> = []+        for site in sites {+            guard seen.insert(site.hostname).inserted else {+                throw BackupValidationError.duplicateHostname(site.hostname)+            }+        }+    }++    // MARK: - Reference Checks++    private static func validateEntryReferences(_ snapshot: LibraryBackupSnapshot) throws {+        let workIDs = Set(snapshot.works.map(\.id))+        let siteHostnames = Set(snapshot.sites.map(\.hostname))+        for entry in snapshot.entries {+            if let workID = entry.workID {+                guard workIDs.contains(workID) else {+                    throw BackupValidationError.unresolvedReference(+                        type: "Entry", id: entry.id,+                        referencedType: "Work", referencedID: workID+                    )+                }+            }+            guard siteHostnames.contains(entry.hostname) else {+                throw BackupValidationError.unresolvedHostname(+                    type: "Entry", id: entry.id.uuidString, hostname: entry.hostname+                )+            }+        }+    }++    private static func validateWorkReferences(_ snapshot: LibraryBackupSnapshot) throws {+        let entryIDs = Set(snapshot.entries.map(\.id))+        let siteHostnames = Set(snapshot.sites.map(\.hostname))+        for work in snapshot.works {+            guard siteHostnames.contains(work.siteHostname) else {+                throw BackupValidationError.unresolvedHostname(+                    type: "Work", id: work.id.uuidString, hostname: work.siteHostname+                )+            }+            for entryID in work.entryIDs {+                guard entryIDs.contains(entryID) else {+                    throw BackupValidationError.unresolvedReference(+                        type: "Work", id: work.id,+                        referencedType: "Entry", referencedID: entryID+                    )+                }+            }+        }+    }++    private static func validateSiteReferences(_ snapshot: LibraryBackupSnapshot) throws {+        let patternIDs = Set(snapshot.titlePatterns.map(\.id))+        for site in snapshot.sites {+            for patternID in site.patternIDs {+                guard patternIDs.contains(patternID) else {+                    throw BackupValidationError.unresolvedReference(+                        type: "Site", id: UUID(), // Sites don't have UUID IDs, using placeholder+                        referencedType: "TitlePattern", referencedID: patternID+                    )+                }+            }+        }+        // Validate titlePattern siteHostname references+        let siteHostnames = Set(snapshot.sites.map(\.hostname))+        for pattern in snapshot.titlePatterns {+            guard siteHostnames.contains(pattern.siteHostname) else {+                throw BackupValidationError.unresolvedHostname(+                    type: "TitlePattern", id: pattern.id.uuidString, hostname: pattern.siteHostname+                )+            }+        }+    }++    private static func validateProvenanceReferences(_ snapshot: LibraryBackupSnapshot) throws {+        let patternsByID: [UUID: [TitlePatternRecord]] = Dictionary(grouping: snapshot.titlePatterns, by: \.id)+        for entry in snapshot.entries {+            try validateProvenance(entry.chapterTitleProvenance, type: "Entry", id: entry.id, patterns: patternsByID)+            try validateProvenance(entry.workAssignmentProvenance, type: "Entry", id: entry.id, patterns: patternsByID)+        }+    }++    private static func validateProvenance(+        _ provenance: FieldProvenance,+        type: String,+        id: UUID,+        patterns: [UUID: [TitlePatternRecord]]+    ) throws {+        guard provenance.kind == .pattern else { return }+        guard let patternID = provenance.patternID,+              let patternVersion = provenance.patternVersion else {+            throw BackupValidationError.unresolvedProvenance(+                type: type, id: id, patternID: UUID(), patternVersion: 0+            )+        }+        guard let matchingPatterns = patterns[patternID],+              matchingPatterns.contains(where: { $0.version == patternVersion }) else {+            throw BackupValidationError.unresolvedProvenance(+                type: type, id: id, patternID: patternID, patternVersion: patternVersion+            )+        }+    }++    // MARK: - Active Pattern Count++    private static func validateActivePatternCounts(_ snapshot: LibraryBackupSnapshot) throws {+        var activeCounts: [String: Int] = [:]+        for pattern in snapshot.titlePatterns where pattern.isActive {+            activeCounts[pattern.siteHostname, default: 0] += 1+        }+        for (hostname, count) in activeCounts where count > 1 {+            throw BackupValidationError.invalidActivePatternCount(hostname: hostname, count: count)+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift Added +30 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift b/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swiftnew file mode 100644index 0000000..a53fc55--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift@@ -0,0 +1,30 @@+import Foundation+import SwiftData++/// Clock boundary used by persistence so tests never depend on wall time.+public protocol RepositoryClock: Sendable {+    func now() -> Date+}++/// Resolves App Group containers outside persistence, enabling deterministic+/// temporary-directory fixtures and fail-closed production resolution.+public protocol SharedContainerLocating: Sendable {+    func containerURL(forAppGroup identifier: String) -> URL?+}++/// Single save boundary for atomic compound operations. Tests inject failures+/// here and verify state through a fresh context rather than reading rolled-back+/// model instances, which SwiftData may leave stale.+public protocol RepositorySaveStrategy: Sendable {+    func save(_ context: ModelContext) throws+}++public struct ModelContextSaveStrategy: RepositorySaveStrategy {+    public init() {}+    public func save(_ context: ModelContext) throws { try context.save() }+}++public enum LibraryLockMode: Sendable {+    case shared+    case exclusive+}
Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift Added +175 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swiftnew file mode 100644index 0000000..127c669--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift@@ -0,0 +1,175 @@+import Foundation++// MARK: - Capture state machine for the extension UI++/// View model state for the capture sheet.+public enum CaptureViewState: Sendable {+    case loading+    case invalidInput(message: String)+    case ready(preparation: CapturePreparation, draft: CaptureDraft)+    case saving(draft: CaptureDraft)+    case saveFailed(preparation: CapturePreparation, draft: CaptureDraft, message: String)+    case saved(entryID: UUID)+    case cancelled++    public var isLoading: Bool {+        if case .loading = self { return true }+        return false+    }+}++/// Manages capture sheet state: title resolution, draft editing, save lifecycle,+/// duplicate-save suppression, draft retention after failure, and cancellation.+@MainActor+public final class CaptureViewModel {+    public private(set) var state: CaptureViewState = .loading+    private var preparation: CapturePreparation?+    private var note: String = ""+    private var rating: Rating? = nil+    private var manualTitle: String = ""++    /// Whether save is currently possible (ready or failed states, with valid title only).+    public var canSave: Bool {+        switch state {+        case .ready(_, let draft):+            return !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty+        case .saveFailed(_, let draft, _):+            return !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty+        default: return false+        }+    }++    /// The current draft being edited, if in a state that has one.+    public var currentDraft: CaptureDraft? {+        switch state {+        case .ready(_, let draft): return draft+        case .saveFailed(_, let draft, _): return draft+        case .saving(let draft): return draft+        default: return nil+        }+    }++    public init() {}++    // MARK: - Lifecycle++    /// Set an invalid-input state directly (e.g., when repository bootstrap fails).+    public func setInvalidInput(message: String) {+        state = .invalidInput(message: message)+    }++    /// Load and prepare a payload, transitioning from loading → ready or invalidInput.+    public func load(payload: SharePayload, coordinator: CaptureCoordinator) async {+        state = .loading++        do {+            let prep = try await coordinator.prepare(payload)+            preparation = prep+            let draft = buildDraft(preparation: prep)+            state = .ready(preparation: prep, draft: draft)+        } catch let error as CapturePreparationError {+            state = .invalidInput(message: error.userMessage)+        } catch {+            state = .invalidInput(message: "Unable to process shared content.")+        }+    }++    // MARK: - Draft editing++    public func setNote(_ value: String) {+        note = value+        rebuildDraft()+    }++    /// Toggle rating: same value clears, different value replaces, nil clears.+    public func setRating(_ value: Rating?) {+        if let value = value, rating == value {+            rating = nil+        } else {+            rating = value+        }+        rebuildDraft()+    }++    public func setManualTitle(_ value: String) {+        manualTitle = value+        rebuildDraft()+    }++    // MARK: - Save++    /// Save the current draft. Suppresses duplicate saves and retains draft on failure.+    public func save(coordinator: CaptureCoordinator) async {+        guard let prep = preparation else { return }+        switch state {+        case .saved: return // Already saved — exactly-once+        case .saving: return // In flight — suppress+        case .cancelled: return // Already cancelled+        default: break+        }++        let draft = buildDraft(preparation: prep)++        // Enforce blank-title rejection: never save with blank title+        guard !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+            return+        }++        state = .saving(draft: draft)+        do {+            let entry = try await coordinator.save(draft)+            // Repository write succeeded — transition to saved before host dismissal+            state = .saved(entryID: entry.id)+        } catch {+            // Retain draft for retry; show non-sensitive error category+            state = .saveFailed(+                preparation: prep,+                draft: draft,+                message: "Unable to save capture. Please try again."+            )+        }+    }++    // MARK: - Cancellation++    /// Cancel the capture flow. No-op after save has succeeded.+    public func cancel() {+        switch state {+        case .saved, .saving: break // Can't cancel after save/during save+        default: state = .cancelled+        }+    }++    // MARK: - Private++    private func rebuildDraft() {+        guard let prep = preparation else { return }+        let draft = buildDraft(preparation: prep)+        switch state {+        case .ready:+            state = .ready(preparation: prep, draft: draft)+        case .saveFailed(_, _, let message):+            state = .saveFailed(preparation: prep, draft: draft, message: message)+        default: break+        }+    }++    private func buildDraft(preparation: CapturePreparation) -> CaptureDraft {+        // Use resolved title verbatim when available; otherwise use manual title verbatim.+        // Blank manual title keeps save disabled (requirement 2.8 — never invent "Untitled").+        let title: String+        if let resolved = preparation.resolvedTitle {+            title = resolved+        } else {+            title = manualTitle+        }+        return CaptureDraft(+            captureTitle: title,+            captureTitleSource: preparation.titleSource,+            rawURLString: preparation.rawURL,+            canonicalURLString: preparation.canonicalURL,+            note: note,+            rating: rating+        )+    }+}
Packages/AsterismCore/Sources/AsterismCore/CrossProcessLibraryLock.swift Added +79 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CrossProcessLibraryLock.swift b/Packages/AsterismCore/Sources/AsterismCore/CrossProcessLibraryLock.swiftnew file mode 100644index 0000000..78fcfbe--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CrossProcessLibraryLock.swift@@ -0,0 +1,79 @@+import Darwin+import Foundation++public enum LibraryRepositoryError: Error, Equatable, Sendable, CustomStringConvertible {+    case libraryBusy(operation: String)+    case libraryUnavailable(operation: String, reason: String)+    case invalidInput(operation: String, reason: String)+    case recordNotFound(type: String, id: UUID)+    case corruptLibrary(operation: String, reason: String)++    public var description: String {+        switch self {+        case .libraryBusy(let operation): "Library is busy while \(operation)"+        case .libraryUnavailable(let operation, let reason): "Library unavailable while \(operation): \(reason)"+        case .invalidInput(let operation, let reason): "Invalid input while \(operation): \(reason)"+        case .recordNotFound(let type, let id): "\(type) \(id) was not found"+        case .corruptLibrary(let operation, let reason): "Library corruption detected while \(operation): \(reason)"+        }+    }+}++public final class LockLease: @unchecked Sendable {+    private var descriptor: Int32++    fileprivate init(descriptor: Int32) {+        self.descriptor = descriptor+    }++    deinit {+        if descriptor >= 0 {+            _ = flock(descriptor, LOCK_UN)+            _ = close(descriptor)+            descriptor = -1+        }+    }+}++public enum CrossProcessLibraryLock {+    public static func acquire(+        mode: LibraryLockMode,+        at url: URL,+        timeout: Duration+    ) async throws -> LockLease {+        let descriptor = open(url.path, O_CREAT | O_RDWR | O_CLOEXEC, S_IRUSR | S_IWUSR)+        guard descriptor >= 0 else {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "opening synchronization lock",+                reason: POSIXErrorCode(rawValue: errno).map(String.init(describing:)) ?? "errno \(errno)"+            )+        }+        _ = fchmod(descriptor, S_IRUSR | S_IWUSR)++        let operation = (mode == .shared ? LOCK_SH : LOCK_EX) | LOCK_NB+        let clock = ContinuousClock()+        let deadline = clock.now.advanced(by: timeout)+        do {+            while true {+                try Task.checkCancellation()+                if flock(descriptor, operation) == 0 {+                    return LockLease(descriptor: descriptor)+                }+                let failure = errno+                guard failure == EWOULDBLOCK || failure == EAGAIN else {+                    throw LibraryRepositoryError.libraryUnavailable(+                        operation: "acquiring synchronization lock",+                        reason: POSIXErrorCode(rawValue: failure).map(String.init(describing:)) ?? "errno \(failure)"+                    )+                }+                guard clock.now < deadline else {+                    throw LibraryRepositoryError.libraryBusy(operation: "acquiring synchronization lock")+                }+                try await Task.sleep(for: .milliseconds(25))+            }+        } catch {+            _ = close(descriptor)+            throw error+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift Added +47 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swiftnew file mode 100644index 0000000..b18f2f4--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift@@ -0,0 +1,47 @@+import Foundation++public enum CaptureTitleSource: String, CaseIterable, Codable, Sendable {+    case host+    case safariDocument+    case networkFetch+    case manual+}++public enum Rating: String, CaseIterable, Codable, Sendable {+    case up+    case down+}++public enum WorkType: String, CaseIterable, Codable, Sendable {+    case novel+    case toon+    case article+    case other+}++public enum TitleProvenance: String, CaseIterable, Codable, Sendable {+    case parsed+    case manual+}++public enum FieldProvenanceKind: String, CaseIterable, Codable, Sendable {+    case none+    case pattern+    case manual+}++public enum SiteMode: String, CaseIterable, Codable, Sendable {+    case untaught+    case taught+    case articles+}++public enum AnchorOrigin: String, CaseIterable, Codable, Sendable {+    case start+    case end+}++public enum URLRuleComponent: String, CaseIterable, Codable, Sendable {+    case pathSegment+    case queryItem+}
Packages/AsterismCore/Sources/AsterismCore/IdentityNormalization.swift Added +203 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/IdentityNormalization.swift b/Packages/AsterismCore/Sources/AsterismCore/IdentityNormalization.swiftnew file mode 100644index 0000000..8b271ff--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/IdentityNormalization.swift@@ -0,0 +1,203 @@+import Foundation++public enum IdentityNormalizationError: Error, Equatable, Sendable, CustomStringConvertible {+    case emptyInput+    case unsupportedScheme(String)+    case missingHost+    case malformedAuthority+    case invalidCharacter++    public var description: String {+        switch self {+        case .emptyInput: "Cannot normalize an empty URL"+        case .unsupportedScheme(let scheme): "Unsupported URL scheme: \(scheme)"+        case .missingHost: "URL does not contain a host"+        case .malformedAuthority: "URL authority is malformed"+        case .invalidCharacter: "URL contains a character that cannot appear in an HTTP URL"+        }+    }+}++public enum HostnameNormalizationError: Error, Equatable, Sendable, CustomStringConvertible {+    case invalidURL(IdentityNormalizationError)+    case blankHostname+    case URLSyntaxNotAllowed+    case invalidHostname++    public var description: String {+        switch self {+        case .invalidURL(let error): "Cannot derive hostname: \(error)"+        case .blankHostname: "Hostname must not be blank"+        case .URLSyntaxNotAllowed: "Expected a bare hostname without URL syntax"+        case .invalidHostname: "Hostname does not follow the accepted grammar"+        }+    }+}++public enum EntryIdentityNormalizer {+    public static let version = 1++    public static func key(forRawURL rawURL: String) throws -> String {+        let parsed = try LexicalHTTPURL(rawURL)+        return parsed.identityKey+    }+}++public enum HostnameNormalizer {+    public static func fromRawURL(_ rawURL: String) throws -> String {+        do {+            var hostname = try LexicalHTTPURL(rawURL).hostname+            if hostname.hasSuffix(".") { hostname.removeLast() }+            guard !hostname.isEmpty else { throw HostnameNormalizationError.invalidHostname }+            return hostname+        } catch let error as IdentityNormalizationError {+            throw HostnameNormalizationError.invalidURL(error)+        }+    }++    public static func bare(_ input: String) throws -> String {+        var hostname = input.trimmingCharacters(in: .whitespacesAndNewlines)+        guard !hostname.isEmpty else { throw HostnameNormalizationError.blankHostname }+        guard hostname.unicodeScalars.allSatisfy({ $0.isASCII }) else {+            throw HostnameNormalizationError.invalidHostname+        }+        guard !hostname.contains(where: { ":/@?#[]".contains($0) }) else {+            throw HostnameNormalizationError.URLSyntaxNotAllowed+        }+        hostname = hostname.asciiLowercased+        if hostname.hasSuffix(".") { hostname.removeLast() }+        guard !hostname.isEmpty, !hostname.hasSuffix("."), hostname.count <= 253 else {+            throw HostnameNormalizationError.invalidHostname+        }+        let labels = hostname.split(separator: ".", omittingEmptySubsequences: false)+        guard labels.allSatisfy({ label in+            !label.isEmpty && label.count <= 63 &&+                label.first != "-" && label.last != "-" &&+                label.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }+        }) else {+            throw HostnameNormalizationError.invalidHostname+        }+        return hostname+    }+}++private struct LexicalHTTPURL {+    let identityKey: String+    let hostname: String++    init(_ rawURL: String) throws {+        guard !rawURL.isEmpty else { throw IdentityNormalizationError.emptyInput }+        guard rawURL.unicodeScalars.allSatisfy({ scalar in+            scalar.isASCII && scalar.value > 0x20 && scalar.value != 0x7f+        }) else {+            throw IdentityNormalizationError.invalidCharacter+        }+        guard let delimiter = rawURL.range(of: "://") else {+            throw IdentityNormalizationError.malformedAuthority+        }+        let rawScheme = String(rawURL[..<delimiter.lowerBound])+        let scheme = rawScheme.asciiLowercased+        guard scheme == "http" || scheme == "https" else {+            throw IdentityNormalizationError.unsupportedScheme(rawScheme)+        }++        let authorityStart = delimiter.upperBound+        let authorityEnd = rawURL[authorityStart...].firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) ?? rawURL.endIndex+        let authority = String(rawURL[authorityStart..<authorityEnd])+        guard !authority.isEmpty else { throw IdentityNormalizationError.missingHost }+        let parsedAuthority = try Self.normalizeAuthority(authority, scheme: scheme)+        hostname = parsedAuthority.hostname++        let suffix = String(rawURL[authorityEnd...])+        let normalizedSuffix = Self.removingTrackers(from: suffix)+        identityKey = scheme + "://" + parsedAuthority.serialized + normalizedSuffix+    }++    private static func normalizeAuthority(_ authority: String, scheme: String) throws -> (serialized: String, hostname: String) {+        let at = authority.lastIndex(of: "@")+        let userInfo = at.map { String(authority[...$0]) } ?? ""+        let hostPort = at.map { String(authority[authority.index(after: $0)...]) } ?? authority+        guard !hostPort.isEmpty else { throw IdentityNormalizationError.missingHost }++        let host: String+        let serializedHost: String+        let port: String?+        if hostPort.hasPrefix("[") {+            guard let closingBracket = hostPort.firstIndex(of: "]") else {+                throw IdentityNormalizationError.malformedAuthority+            }+            let hostStart = hostPort.index(after: hostPort.startIndex)+            let rawHost = String(hostPort[hostStart..<closingBracket])+            guard !rawHost.isEmpty, rawHost.allSatisfy({ $0.isASCII && ($0.isHexDigit || $0 == ":" || $0 == ".") }) else {+                throw IdentityNormalizationError.malformedAuthority+            }+            host = rawHost.asciiLowercased+            serializedHost = "[\(host)]"+            let remainder = String(hostPort[hostPort.index(after: closingBracket)...])+            if remainder.isEmpty {+                port = nil+            } else {+                guard remainder.first == ":" else { throw IdentityNormalizationError.malformedAuthority }+                port = String(remainder.dropFirst())+            }+        } else {+            guard hostPort.filter({ $0 == ":" }).count <= 1 else {+                throw IdentityNormalizationError.malformedAuthority+            }+            if let colon = hostPort.lastIndex(of: ":") {+                host = String(hostPort[..<colon]).asciiLowercased+                port = String(hostPort[hostPort.index(after: colon)...])+            } else {+                host = hostPort.asciiLowercased+                port = nil+            }+            guard !host.isEmpty, host.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }) else {+                throw IdentityNormalizationError.missingHost+            }+            serializedHost = host+        }++        var normalizedPort: String?+        if let port {+            guard !port.isEmpty, port.allSatisfy(\.isNumber) else {+                throw IdentityNormalizationError.malformedAuthority+            }+            let numeric = port.drop(while: { $0 == "0" })+            let normalizedNumeric = numeric.isEmpty ? "0" : String(numeric)+            let isDefault = (scheme == "http" && normalizedNumeric == "80") ||+                (scheme == "https" && normalizedNumeric == "443")+            if !isDefault { normalizedPort = port }+        }+        return (userInfo + serializedHost + (normalizedPort.map { ":\($0)" } ?? ""), host)+    }++    private static func removingTrackers(from suffix: String) -> String {+        guard let question = suffix.firstIndex(of: "?") else { return suffix }+        let fragment = suffix[question...].firstIndex(of: "#")+        let queryEnd = fragment ?? suffix.endIndex+        let prefix = String(suffix[..<question])+        let queryStart = suffix.index(after: question)+        let query = suffix[queryStart..<queryEnd]+        let retained = query.split(separator: "&", omittingEmptySubsequences: false).filter { component in+            let equals = component.firstIndex(of: "=") ?? component.endIndex+            return !isTrackerName(component[..<equals])+        }+        let fragmentText = fragment.map { String(suffix[$0...]) } ?? ""+        guard !retained.isEmpty else { return prefix + fragmentText }+        return prefix + "?" + retained.map(String.init).joined(separator: "&") + fragmentText+    }++    private static func isTrackerName(_ name: Substring) -> Bool {+        let lower = String(name).asciiLowercased+        if lower.hasPrefix("utm_") { return true }+        return ["fbclid", "gclid", "dclid", "msclkid", "gbraid", "wbraid", "mc_cid", "mc_eid"].contains(lower)+    }+}++private extension String {+    var asciiLowercased: String {+        String(decoding: utf8.map { byte in+            byte >= 65 && byte <= 90 ? byte + 32 : byte+        }, as: UTF8.self)+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift Added +54 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftnew file mode 100644index 0000000..c0757c6--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -0,0 +1,54 @@+import Foundation++/// Build-time library identity. Personal and Development must never resolve to+/// the same App Group; making the identity explicit prevents silent fallback.+public enum LibraryEnvironment: String, CaseIterable, Sendable {+    case personal+    case development++    public var appGroupIdentifier: String {+        switch self {+        case .personal:+            "group.me.nore.ig.Asterism"+        case .development:+            "group.me.nore.ig.Asterism.dev"+        }+    }++    /// Compile-time environment resolved from build configuration.+    /// Development builds use `.development`; Personal/Release builds use `.personal`.+    public static var current: LibraryEnvironment {+        #if DEBUG+        .development+        #else+        .personal+        #endif+    }+}++/// Immutable paths and operational timeouts injected into the repository.+public struct LibraryConfiguration: Sendable, Equatable {+    public static let storeRelativePath = "Library/Application Support/Asterism.sqlite"+    public static let lockFilename = "Asterism.lock"+    public static let markerFilename = "Asterism.initialized"++    public let rootDirectory: URL+    public let environment: LibraryEnvironment++    public init(rootDirectory: URL, environment: LibraryEnvironment) {+        self.rootDirectory = rootDirectory+        self.environment = environment+    }++    public var storeURL: URL {+        rootDirectory.appending(path: Self.storeRelativePath)+    }++    public var lockURL: URL {+        rootDirectory.appending(path: Self.lockFilename)+    }++    public var markerURL: URL {+        rootDirectory.appending(path: Self.markerFilename)+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Added +18 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftnew file mode 100644index 0000000..dd44967--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -0,0 +1,18 @@+import Foundation++/// Test seam for the app's view-model layer to consume repository operations+/// without coupling directly to the actor or its persistence internals.+public protocol LibraryProviding: Sendable {+    func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup]+    func works() async throws -> WorksSnapshot+    func entry(id: UUID) async throws -> EntrySnapshot+    func work(id: UUID) async throws -> WorkSnapshot+    func updateEntry(id: UUID, note: String, rating: Rating?) async throws+    func deleteEntry(id: UUID) async throws+    func createWork(_ draft: NewWorkDraft) async throws -> WorkSnapshot+    func updateWork(id: UUID, draft: WorkMetadataDraft) async throws+    func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws+    func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot]+}++extension LibraryRepository: LibraryProviding {}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Added +671 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftnew file mode 100644index 0000000..c79563e--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -0,0 +1,671 @@+import Foundation+import OSLog+import SwiftData++public struct LibraryRecordCounts: Equatable, Sendable {+    public static let zero = LibraryRecordCounts(entries: 0, works: 0, sites: 0, titlePatterns: 0)++    public let entries: Int+    public let works: Int+    public let sites: Int+    public let titlePatterns: Int++    public init(entries: Int, works: Int, sites: Int, titlePatterns: Int) {+        self.entries = entries+        self.works = works+        self.sites = sites+        self.titlePatterns = titlePatterns+    }+}++public struct SystemSharedContainerLocator: SharedContainerLocating {+    public init() {}++    public func containerURL(forAppGroup identifier: String) -> URL? {+        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)+    }+}++public extension LibraryConfiguration {+    static func production(+        environment: LibraryEnvironment,+        locator: any SharedContainerLocating = SystemSharedContainerLocator()+    ) throws -> LibraryConfiguration {+        guard let root = locator.containerURL(forAppGroup: environment.appGroupIdentifier) else {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "resolving \(environment.rawValue) App Group",+                reason: "the configured shared container is unavailable"+            )+        }+        return LibraryConfiguration(rootDirectory: root, environment: environment)+    }+}++public actor LibraryRepository {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "LibraryRepository")+    private static let interactiveLockTimeout: Duration = .seconds(2)+    private static let bootstrapLockTimeout: Duration = .seconds(5)++    private let configuration: LibraryConfiguration+    private let container: ModelContainer+    private let clock: any RepositoryClock+    private let saveStrategy: any RepositorySaveStrategy++    private init(+        configuration: LibraryConfiguration,+        container: ModelContainer,+        clock: any RepositoryClock,+        saveStrategy: any RepositorySaveStrategy+    ) {+        self.configuration = configuration+        self.container = container+        self.clock = clock+        self.saveStrategy = saveStrategy+    }++    public static func open(+        _ configuration: LibraryConfiguration,+        clock: any RepositoryClock = SystemRepositoryClock(),+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) async throws -> LibraryRepository {+        logger.debug("Opening \(configuration.environment.rawValue, privacy: .public) library")+        do {+            try FileManager.default.createDirectory(at: configuration.rootDirectory, withIntermediateDirectories: true)+            try FileManager.default.createDirectory(+                at: configuration.storeURL.deletingLastPathComponent(),+                withIntermediateDirectories: true+            )+        } catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "creating library directories",+                reason: String(describing: error)+            )+        }++        let lease = try await CrossProcessLibraryLock.acquire(+            mode: .exclusive,+            at: configuration.lockURL,+            timeout: bootstrapLockTimeout+        )+        defer { withExtendedLifetime(lease) {} }++        let markerExists = FileManager.default.fileExists(atPath: configuration.markerURL.path)+        let storeExists = FileManager.default.fileExists(atPath: configuration.storeURL.path)+        guard !(markerExists && !storeExists) else {+            logger.error("Refusing to replace a missing established library")+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "opening established library",+                reason: "initialization marker exists but the store is missing"+            )+        }++        let container: ModelContainer+        do {+            let schema = Schema(versionedSchema: AsterismSchemaV1.self)+            let storeConfiguration = ModelConfiguration(+                "AsterismV1",+                schema: schema,+                url: configuration.storeURL,+                cloudKitDatabase: .none+            )+            container = try ModelContainer(+                for: schema,+                migrationPlan: AsterismMigrationPlan.self,+                configurations: [storeConfiguration]+            )+            let context = ModelContext(container)+            if !storeExists { try context.save() }+            try validateStore(using: context)+        } catch {+            logger.error("Store open or validation failed: \(String(describing: error), privacy: .public)")+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "opening and validating V1 store",+                reason: String(describing: error)+            )+        }++        if !markerExists {+            do {+                try Data("Asterism library initialized\n".utf8).write(to: configuration.markerURL, options: .atomic)+                try FileManager.default.setAttributes(+                    [.posixPermissions: NSNumber(value: Int16(0o600))],+                    ofItemAtPath: configuration.markerURL.path+                )+            } catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "recording library initialization",+                    reason: String(describing: error)+                )+            }+        }++        logger.debug("Library bootstrap validated")+        return LibraryRepository(+            configuration: configuration,+            container: container,+            clock: clock,+            saveStrategy: saveStrategy+        )+    }++    public func siteStatus(forRawURL rawURL: String) async throws -> CaptureSiteStatus {+        let hostname: String+        do { hostname = try HostnameNormalizer.fromRawURL(rawURL) }+        catch { throw LibraryRepositoryError.invalidInput(operation: "checking capture Site", reason: String(describing: error)) }++        return try await withLockedContext(mode: .shared, operation: "checking capture Site") { context in+            let sites = try Self.fetchSites(hostname: hostname, context: context)+            guard let site = sites.first else { return .newSite }+            return site.mode == .untaught ? .existingUntaught : .existingTaught+        }+    }++    public func capture(_ draft: CaptureDraft) async throws -> EntrySnapshot {+        let validated = try Self.validateCapture(draft)+        Self.logger.debug("Capturing Entry for a validated Site boundary")+        return try await withLockedContext(mode: .exclusive, operation: "saving capture") { context in+            let sites = try Self.fetchSites(hostname: validated.hostname, context: context)+            if sites.isEmpty {+                context.insert(Site(hostname: validated.hostname))+                Self.logger.debug("Capture creates the first Site for its hostname")+            } else {+                Self.logger.debug("Capture reuses an existing Site")+            }++            let timestamp = clock.now()+            let entry = Entry(+                captureTitle: draft.captureTitle,+                captureTitleSource: draft.captureTitleSource,+                rawURLString: draft.rawURLString,+                canonicalURLString: draft.canonicalURLString,+                hostname: validated.hostname,+                entryIdentityKey: validated.identityKey,+                timestamp: timestamp,+                note: draft.note,+                rating: draft.rating+            )+            context.insert(entry)+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "atomically saving Entry and Site",+                    reason: String(describing: error)+                )+            }+            return try Self.snapshot(entry)+        }+    }++    public func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup] {+        try await withLockedContext(mode: .shared, operation: "reading Recent entries") { context in+            let snapshots = try context.fetch(FetchDescriptor<Entry>()).map(Self.snapshot)+            let sorted = snapshots.sorted {+                if $0.lastSharedAt != $1.lastSharedAt { return $0.lastSharedAt > $1.lastSharedAt }+                return $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased()+            }+            var grouped: [(Date, [EntrySnapshot])] = []+            for entry in sorted {+                let day = calendar.startOfDay(for: entry.lastSharedAt)+                if let last = grouped.indices.last, grouped[last].0 == day {+                    grouped[last].1.append(entry)+                } else {+                    grouped.append((day, [entry]))+                }+            }+            return grouped.map { DatedEntryGroup(day: $0.0, entries: $0.1) }+        }+    }++    public func entry(id: UUID) async throws -> EntrySnapshot {+        try await withLockedContext(mode: .shared, operation: "reading Entry") { context in+            try Self.snapshot(Self.fetchEntry(id: id, context: context))+        }+    }++    public func updateEntry(id: UUID, note: String, rating: Rating?) async throws {+        try await withLockedContext(mode: .exclusive, operation: "updating Entry") { context in+            let entry = try Self.fetchEntry(id: id, context: context)+            entry.note = note+            entry.rating = rating+            entry.modifiedAt = clock.now()+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "saving Entry note and rating",+                    reason: String(describing: error)+                )+            }+        }+    }++    public func deleteEntry(id: UUID) async throws {+        try await withLockedContext(mode: .exclusive, operation: "deleting Entry") { context in+            let entry = try Self.fetchEntry(id: id, context: context)+            if let work = entry.work { work.modifiedAt = clock.now() }+            context.delete(entry)+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "atomically deleting Entry",+                    reason: String(describing: error)+                )+            }+        }+    }++    public func createWork(_ draft: NewWorkDraft) async throws -> WorkSnapshot {+        guard !draft.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+            throw LibraryRepositoryError.invalidInput(operation: "creating Work", reason: "display title is blank")+        }+        let hostname: String+        do { hostname = try HostnameNormalizer.bare(draft.hostname) }+        catch { throw LibraryRepositoryError.invalidInput(operation: "validating Work hostname", reason: String(describing: error)) }++        return try await withLockedContext(mode: .exclusive, operation: "creating Work") { context in+            let sites = try Self.fetchSites(hostname: hostname, context: context)+            if sites.isEmpty { context.insert(Site(hostname: hostname)) }+            let timestamp = MillisecondInstant.quantize(clock.now())+            let work = Work(displayTitle: draft.displayTitle, siteHostname: hostname, timestamp: timestamp)+            context.insert(work)+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "atomically saving Work and Site",+                    reason: String(describing: error)+                )+            }+            return try Self.snapshot(work)+        }+    }++    public func works() async throws -> WorksSnapshot {+        try await withLockedContext(mode: .shared, operation: "reading Works") { context in+            let workSnapshots = try context.fetch(FetchDescriptor<Work>()).map(Self.snapshot)+            let sortedWorks = workSnapshots.sorted { left, right in+                let leftNewest = left.entries.first?.lastSharedAt+                let rightNewest = right.entries.first?.lastSharedAt+                switch (leftNewest, rightNewest) {+                case let (left?, right?):+                    if left != right { return left > right }+                case (_?, nil): return true+                case (nil, _?): return false+                case (nil, nil):+                    if left.modifiedAt != right.modifiedAt { return left.modifiedAt > right.modifiedAt }+                }+                return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()+            }+            let unattachedDescriptor = FetchDescriptor<Entry>(+                predicate: #Predicate<Entry> { $0.work == nil }+            )+            let unattached = try context.fetch(unattachedDescriptor)+                .map(Self.snapshot)+                .sorted(by: Self.entryActivityOrder)+            return WorksSnapshot(works: sortedWorks, unattachedEntries: unattached)+        }+    }++    public func work(id: UUID) async throws -> WorkSnapshot {+        try await withLockedContext(mode: .shared, operation: "reading Work") { context in+            try Self.snapshot(Self.fetchWork(id: id, context: context))+        }+    }++    public func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot] {+        try await withLockedContext(mode: .shared, operation: "reading Work destinations") { context in+            let entry = try Self.fetchEntry(id: entryID, context: context)+            return try context.fetch(FetchDescriptor<Work>())+                .filter { $0.siteHostname == entry.hostname }+                .map(Self.snapshot)+                .sorted {+                    let titleOrder = $0.displayTitle.localizedStandardCompare($1.displayTitle)+                    if titleOrder != .orderedSame { return titleOrder == .orderedAscending }+                    return $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased()+                }+        }+    }++    public func updateWork(id: UUID, draft: WorkMetadataDraft) async throws {+        guard !draft.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+            throw LibraryRepositoryError.invalidInput(operation: "updating Work", reason: "display title is blank")+        }+        let normalizedTags = Self.normalizeTags(draft.genreTags)+        try await withLockedContext(mode: .exclusive, operation: "updating Work") { context in+            let work = try Self.fetchWork(id: id, context: context)+            if work.displayTitle != draft.displayTitle { work.titleProvenance = .manual }+            work.displayTitle = draft.displayTitle+            work.type = draft.type+            work.genreTags = normalizedTags+            work.genericNotes = draft.genericNotes+            work.modifiedAt = MillisecondInstant.quantize(clock.now())+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "atomically saving Work metadata",+                    reason: String(describing: error)+                )+            }+        }+    }++    public func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws {+        if case .newWork(let title) = destination,+           title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+            throw LibraryRepositoryError.invalidInput(operation: "moving Entry to new Work", reason: "display title is blank")+        }+        try await withLockedContext(mode: .exclusive, operation: "moving Entry") { context in+            let entry = try Self.fetchEntry(id: entryID, context: context)+            let timestamp = MillisecondInstant.quantize(clock.now())+            let priorWork = entry.work++            switch destination {+            case .existing(let workID):+                let destinationWork = try Self.fetchWork(id: workID, context: context)+                guard destinationWork.siteHostname == entry.hostname else {+                    throw LibraryRepositoryError.invalidInput(+                        operation: "moving Entry",+                        reason: "destination Work belongs to a different hostname"+                    )+                }+                if priorWork?.id == destinationWork.id { return }+                priorWork?.modifiedAt = timestamp+                destinationWork.modifiedAt = timestamp+                entry.work = destinationWork+                entry.intentionallyUnattached = false+            case .newWork(let displayTitle):+                let work = Work(displayTitle: displayTitle, siteHostname: entry.hostname, timestamp: timestamp)+                context.insert(work)+                priorWork?.modifiedAt = timestamp+                entry.work = work+                entry.intentionallyUnattached = false+            case .unattached:+                if priorWork == nil,+                   entry.intentionallyUnattached,+                   entry.workAssignmentProvenance == .manual { return }+                priorWork?.modifiedAt = timestamp+                entry.work = nil+                entry.intentionallyUnattached = true+            }++            entry.workAssignmentProvenance = .manual+            entry.workPatternID = nil+            entry.workPatternVersion = nil+            entry.modifiedAt = timestamp+            do { try saveStrategy.save(context) }+            catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "atomically saving Work assignment",+                    reason: String(describing: error)+                )+            }+        }+    }++    public func debugCounts() async throws -> LibraryRecordCounts {+        try await withLockedContext(mode: .shared, operation: "reading coherent library counts") { context in+            try LibraryRecordCounts(+                entries: context.fetchCount(FetchDescriptor<Entry>()),+                works: context.fetchCount(FetchDescriptor<Work>()),+                sites: context.fetchCount(FetchDescriptor<Site>()),+                titlePatterns: context.fetchCount(FetchDescriptor<TitlePattern>())+            )+        }+    }++    // MARK: - Backup Support++    internal func withLockedBackupContext<Value: Sendable>(+        _ body: (ModelContext) throws -> Value+    ) async throws -> Value {+        try await withLockedContext(mode: .shared, operation: "reading backup snapshot", body)+    }++    internal static func mapEntryRecord(_ entry: Entry) throws -> EntryRecord {+        let snap = try snapshot(entry)+        return EntryRecord(+            id: snap.id,+            captureTitle: snap.captureTitle,+            captureTitleSource: snap.captureTitleSource,+            rawURL: snap.rawURLString,+            canonicalURL: snap.canonicalURLString,+            hostname: snap.hostname,+            entryIdentityKey: snap.entryIdentityKey,+            identityKeyVersion: snap.identityKeyVersion,+            chapterTitle: snap.chapterTitle,+            chapterTitleProvenance: snap.chapterTitleProvenance,+            note: snap.note,+            rating: snap.rating,+            firstCapturedAt: snap.firstCapturedAt,+            lastSharedAt: snap.lastSharedAt,+            modifiedAt: snap.modifiedAt,+            workID: snap.workID,+            workAssignmentProvenance: snap.workAssignmentProvenance,+            intentionallyUnattached: snap.intentionallyUnattached+        )+    }++    internal static func mapWorkRecord(_ work: Work) throws -> WorkRecord {+        guard let type = WorkType(rawValue: work.typeRaw) else {+            throw LibraryRepositoryError.corruptLibrary(operation: "mapping Work for backup", reason: "invalid type")+        }+        guard let provenance = TitleProvenance(rawValue: work.titleProvenanceRaw) else {+            throw LibraryRepositoryError.corruptLibrary(operation: "mapping Work for backup", reason: "invalid provenance")+        }+        let entryIDs = work.entryValues.map(\.id)+            .sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        return WorkRecord(+            id: work.id,+            displayTitle: work.displayTitle,+            lastParsedTitle: work.lastParsedTitle,+            siteHostname: work.siteHostname,+            urlIdentity: work.urlIdentity,+            workURL: work.workURLString,+            genericNotes: work.genericNotes,+            type: type,+            genreTags: work.genreTags,+            titleProvenance: provenance,+            createdAt: work.createdAt,+            modifiedAt: work.modifiedAt,+            entryIDs: entryIDs+        )+    }++    internal static func mapSiteRecord(_ site: Site) -> SiteRecord {+        let patternIDs = site.patternValues.map(\.id)+            .sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        return SiteRecord(+            hostname: site.hostname,+            displayName: site.displayName,+            mode: site.mode,+            patternIDs: patternIDs,+            urlIdentityRule: site.urlIdentityRule,+            junkSuffixRule: site.junkSuffixRule+        )+    }++    internal static func mapTitlePatternRecord(_ pattern: TitlePattern) -> TitlePatternRecord {+        TitlePatternRecord(+            id: pattern.id,+            version: pattern.version,+            isActive: pattern.isActive,+            createdAt: pattern.createdAt,+            workAnchor: pattern.workAnchor,+            junkAnchors: pattern.junkAnchors,+            siteHostname: pattern.site?.hostname ?? ""+        )+    }++    private func withLockedContext<Value: Sendable>(+        mode: LibraryLockMode,+        operation: String,+        _ body: (ModelContext) throws -> Value+    ) async throws -> Value {+        let lease = try await CrossProcessLibraryLock.acquire(+            mode: mode,+            at: configuration.lockURL,+            timeout: Self.interactiveLockTimeout+        )+        defer { withExtendedLifetime(lease) {} }+        let context = ModelContext(container)+        do { return try body(context) }+        catch let error as LibraryRepositoryError { throw error }+        catch {+            throw LibraryRepositoryError.libraryUnavailable(operation: operation, reason: String(describing: error))+        }+    }++    private static func fetchSites(hostname: String, context: ModelContext) throws -> [Site] {+        var descriptor = FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname })+        descriptor.fetchLimit = 2+        let sites = try context.fetch(descriptor)+        guard sites.count <= 1 else {+            throw LibraryRepositoryError.corruptLibrary(+                operation: "resolving Site",+                reason: "multiple Sites share hostname \(hostname)"+            )+        }+        return sites+    }++    private static func fetchEntry(id: UUID, context: ModelContext) throws -> Entry {+        var descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id })+        descriptor.fetchLimit = 2+        let entries = try context.fetch(descriptor)+        guard entries.count <= 1 else {+            throw LibraryRepositoryError.corruptLibrary(operation: "resolving Entry", reason: "duplicate application UUID")+        }+        guard let entry = entries.first else { throw LibraryRepositoryError.recordNotFound(type: "Entry", id: id) }+        return entry+    }++    private static func fetchWork(id: UUID, context: ModelContext) throws -> Work {+        var descriptor = FetchDescriptor<Work>(predicate: #Predicate { $0.id == id })+        descriptor.fetchLimit = 2+        let works = try context.fetch(descriptor)+        guard works.count <= 1 else {+            throw LibraryRepositoryError.corruptLibrary(operation: "resolving Work", reason: "duplicate application UUID")+        }+        guard let work = works.first else { throw LibraryRepositoryError.recordNotFound(type: "Work", id: id) }+        return work+    }++    private static func snapshot(_ work: Work) throws -> WorkSnapshot {+        guard let type = WorkType(rawValue: work.typeRaw) else {+            throw LibraryRepositoryError.corruptLibrary(operation: "mapping Work", reason: "invalid Work type")+        }+        guard let provenance = TitleProvenance(rawValue: work.titleProvenanceRaw) else {+            throw LibraryRepositoryError.corruptLibrary(operation: "mapping Work", reason: "invalid title provenance")+        }+        let entries = try work.entryValues.map(snapshot).sorted(by: entryActivityOrder)+        return WorkSnapshot(+            id: work.id,+            displayTitle: work.displayTitle,+            lastParsedTitle: work.lastParsedTitle,+            siteHostname: work.siteHostname,+            urlIdentity: work.urlIdentity,+            workURLString: work.workURLString,+            genericNotes: work.genericNotes,+            type: type,+            genreTags: work.genreTags,+            titleProvenance: provenance,+            createdAt: work.createdAt,+            modifiedAt: work.modifiedAt,+            entries: entries+        )+    }++    private static func entryActivityOrder(_ left: EntrySnapshot, _ right: EntrySnapshot) -> Bool {+        if left.lastSharedAt != right.lastSharedAt { return left.lastSharedAt > right.lastSharedAt }+        return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()+    }++    private static func normalizeTags(_ tags: [String]) -> [String] {+        var seen: Set<String> = []+        var result: [String] = []+        for tag in tags {+            let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)+            guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue }+            result.append(trimmed)+        }+        return result+    }++    private static func validateCapture(_ draft: CaptureDraft) throws -> (hostname: String, identityKey: String) {+        guard !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+            throw LibraryRepositoryError.invalidInput(operation: "validating capture", reason: "capture title is blank")+        }+        let hostname: String+        let key: String+        do {+            hostname = try HostnameNormalizer.fromRawURL(draft.rawURLString)+            key = try EntryIdentityNormalizer.key(forRawURL: draft.rawURLString)+        } catch {+            throw LibraryRepositoryError.invalidInput(operation: "validating raw capture URL", reason: String(describing: error))+        }+        if let canonical = draft.canonicalURLString, !isValidHTTPURL(canonical) {+            throw LibraryRepositoryError.invalidInput(operation: "validating canonical URL", reason: "canonical URL must be absolute HTTP(S)")+        }+        return (hostname, key)+    }++    private static func isValidHTTPURL(_ value: String) -> Bool {+        guard let components = URLComponents(string: value),+              let scheme = components.scheme?.lowercased(),+              scheme == "http" || scheme == "https",+              components.host?.isEmpty == false else { return false }+        return true+    }++    private static func snapshot(_ entry: Entry) throws -> EntrySnapshot {+        let chapter = try FieldProvenance(+            kind: FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw) ?? .none,+            patternID: entry.chapterPatternID,+            patternVersion: entry.chapterPatternVersion+        )+        let assignment = try FieldProvenance(+            kind: FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) ?? .none,+            patternID: entry.workPatternID,+            patternVersion: entry.workPatternVersion+        )+        guard let titleSource = CaptureTitleSource(rawValue: entry.captureTitleSourceRaw) else {+            throw LibraryRepositoryError.corruptLibrary(operation: "mapping Entry", reason: "invalid title source")+        }+        let rating: Rating?+        if let raw = entry.ratingRaw {+            guard let parsed = Rating(rawValue: raw) else {+                throw LibraryRepositoryError.corruptLibrary(operation: "mapping Entry", reason: "invalid rating")+            }+            rating = parsed+        } else { rating = nil }+        return EntrySnapshot(+            id: entry.id,+            captureTitle: entry.captureTitle,+            captureTitleSource: titleSource,+            rawURLString: entry.rawURLString,+            canonicalURLString: entry.canonicalURLString,+            hostname: entry.hostname,+            entryIdentityKey: entry.entryIdentityKey,+            identityKeyVersion: entry.identityKeyVersion,+            chapterTitle: entry.chapterTitle,+            chapterTitleProvenance: chapter,+            note: entry.note,+            rating: rating,+            firstCapturedAt: entry.firstCapturedAt,+            lastSharedAt: entry.lastSharedAt,+            modifiedAt: entry.modifiedAt,+            workID: entry.work?.id,+            workAssignmentProvenance: assignment,+            intentionallyUnattached: entry.intentionallyUnattached+        )+    }++    private static func validateStore(using context: ModelContext) throws {+        var entries = FetchDescriptor<Entry>(); entries.fetchLimit = 1+        var works = FetchDescriptor<Work>(); works.fetchLimit = 1+        var sites = FetchDescriptor<Site>(); sites.fetchLimit = 1+        var patterns = FetchDescriptor<TitlePattern>(); patterns.fetchLimit = 1+        _ = try context.fetch(entries)+        _ = try context.fetch(works)+        _ = try context.fetch(sites)+        _ = try context.fetch(patterns)+    }+}
Packages/AsterismCore/Sources/AsterismCore/Models.swift Added +166 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftnew file mode 100644index 0000000..061b911--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -0,0 +1,166 @@+import Foundation+import SwiftData++@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 entryIdentityKey: String = ""+    public var identityKeyVersion: Int = 1+    public var chapterTitle: String?+    public var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue+    public var chapterPatternID: UUID?+    public var chapterPatternVersion: Int?+    public var note: String = ""+    public var ratingRaw: String?+    public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)+    public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)+    public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+    public var work: Work?+    public var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue+    public var workPatternID: UUID?+    public var workPatternVersion: Int?+    public var intentionallyUnattached: Bool = false++    public init(+        id: UUID = UUID(),+        captureTitle: String,+        captureTitleSource: CaptureTitleSource,+        rawURLString: String,+        canonicalURLString: String? = nil,+        hostname: String,+        entryIdentityKey: String,+        timestamp: Date,+        note: String = "",+        rating: Rating? = nil,+        work: Work? = nil+    ) {+        self.id = id+        self.captureTitle = captureTitle+        captureTitleSourceRaw = captureTitleSource.rawValue+        self.rawURLString = rawURLString+        self.canonicalURLString = canonicalURLString+        self.hostname = hostname+        self.entryIdentityKey = entryIdentityKey+        self.note = note+        ratingRaw = rating?.rawValue+        firstCapturedAt = timestamp+        lastSharedAt = timestamp+        modifiedAt = timestamp+        self.work = work+    }++    public var captureTitleSource: CaptureTitleSource {+        get { CaptureTitleSource(rawValue: captureTitleSourceRaw) ?? .manual }+        set { captureTitleSourceRaw = newValue.rawValue }+    }++    public var rating: Rating? {+        get { ratingRaw.flatMap(Rating.init(rawValue:)) }+        set { ratingRaw = newValue?.rawValue }+    }++    public var chapterTitleProvenance: FieldProvenanceKind {+        get { FieldProvenanceKind(rawValue: chapterTitleProvenanceRaw) ?? .none }+        set { chapterTitleProvenanceRaw = newValue.rawValue }+    }++    public var workAssignmentProvenance: FieldProvenanceKind {+        get { FieldProvenanceKind(rawValue: workAssignmentProvenanceRaw) ?? .none }+        set { workAssignmentProvenanceRaw = newValue.rawValue }+    }+}++@Model+public final class Work {+    public var id: UUID = UUID()+    public var displayTitle: String = ""+    public var lastParsedTitle: String?+    public var siteHostname: String = ""+    public var urlIdentity: String?+    public var workURLString: String?+    public var genericNotes: String = ""+    public var typeRaw: String = WorkType.other.rawValue+    public var genreTags: [String] = []+    public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+    public var createdAt: Date = Date(timeIntervalSince1970: 0)+    public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+    @Relationship(deleteRule: .nullify, inverse: \Entry.work)+    public var entries: [Entry]?++    public init(id: UUID = UUID(), displayTitle: String, siteHostname: String, timestamp: Date) {+        self.id = id+        self.displayTitle = displayTitle+        self.siteHostname = siteHostname+        createdAt = timestamp+        modifiedAt = timestamp+    }++    public var type: WorkType {+        get { WorkType(rawValue: typeRaw) ?? .other }+        set { typeRaw = newValue.rawValue }+    }++    public var titleProvenance: TitleProvenance {+        get { TitleProvenance(rawValue: titleProvenanceRaw) ?? .manual }+        set { titleProvenanceRaw = newValue.rawValue }+    }++    public var entryValues: [Entry] { entries ?? [] }+}++@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]?+    public var urlIdentityRule: URLIdentityRule?+    public var junkSuffixRule: JunkSuffixRule?++    public init(hostname: String, displayName: String? = nil) {+        self.hostname = hostname+        self.displayName = displayName ?? hostname+    }++    public var mode: SiteMode {+        get { SiteMode(rawValue: modeRaw) ?? .untaught }+        set { modeRaw = newValue.rawValue }+    }++    public var patternValues: [TitlePattern] { patterns ?? [] }+}++@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 workAnchor: SegmentRangeSpec = try! SegmentRangeSpec(origin: .start, offset: 0, length: 1)+    public var junkAnchors: [SegmentPositionSpec] = []+    public var site: Site?++    public init(+        id: UUID = UUID(),+        version: Int,+        isActive: Bool = false,+        createdAt: Date,+        workAnchor: SegmentRangeSpec,+        junkAnchors: [SegmentPositionSpec] = [],+        site: Site? = nil+    ) {+        self.id = id+        self.version = version+        self.isActive = isActive+        self.createdAt = createdAt+        self.workAnchor = workAnchor+        self.junkAnchors = junkAnchors+        self.site = site+    }+}
Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift Added +686 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swiftnew file mode 100644index 0000000..d078373--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift@@ -0,0 +1,686 @@+import Foundation++// MARK: - Head metadata scanner result++/// Result produced by the bounded HTML head scanner.+public struct ScannedHeadMetadata: Sendable, Equatable {+    /// The first nonblank `<title>` text with entities decoded.+    public let title: String?+    /// All canonical link href values in document order.+    public let canonicalCandidates: [String]++    public init(title: String? = nil, canonicalCandidates: [String] = []) {+        self.title = title+        self.canonicalCandidates = canonicalCandidates+    }+}++// MARK: - Bounded head metadata scanner++/// Streaming bounded scanner that extracts `<title>` and canonical `<link>` candidates+/// from the first ≤65,536 bytes of an HTML `<head>`. Stops at `</head>` or the byte limit.+/// Handles quoted/unquoted attributes, ASCII-case-insensitive tags, and whitespace-tokenized `rel`.+/// Decodes numeric and the five named HTML entities (amp, lt, gt, quot, apos);+/// unknown named entities remain literal.+public struct HeadMetadataScanner: Sendable {+    public init() {}++    /// Scan data with optional HTTP-declared charset for title/canonical extraction.+    public func scan(_ data: Data, httpCharset: String? = nil) -> ScannedHeadMetadata {+        let text = decodeText(data, httpCharset: httpCharset)+        return parse(text)+    }++    // MARK: - Character decoding++    private func decodeText(_ data: Data, httpCharset: String?) -> String {+        // Priority: HTTP charset → BOM → meta charset in first 1024 bytes → UTF-8 → Windows-1252+        if let charset = httpCharset, let encoding = encodingForName(charset) {+            if let str = String(data: data, encoding: encoding) { return str }+        }+        // Check BOM+        if data.count >= 3, data[0] == 0xEF, data[1] == 0xBB, data[2] == 0xBF {+            if let str = String(data: data.dropFirst(3), encoding: .utf8) { return str }+        }+        if data.count >= 2, data[0] == 0xFE, data[1] == 0xFF {+            if let str = String(data: data.dropFirst(2), encoding: .utf16BigEndian) { return str }+        }+        if data.count >= 2, data[0] == 0xFF, data[1] == 0xFE {+            if let str = String(data: data.dropFirst(2), encoding: .utf16LittleEndian) { return str }+        }+        // Try meta charset in first 1024 bytes+        let prefixData = data.prefix(1024)+        if let prefixStr = String(data: prefixData, encoding: .ascii) ?? String(data: prefixData, encoding: .utf8) {+            if let metaCharset = extractMetaCharset(from: prefixStr),+               let encoding = encodingForName(metaCharset),+               let str = String(data: data, encoding: encoding) {+                return str+            }+        }+        // UTF-8 fallback+        if let str = String(data: data, encoding: .utf8) { return str }+        // Windows-1252 fallback+        if let str = String(data: data, encoding: .windowsCP1252) { return str }+        return ""+    }++    private func extractMetaCharset(from text: String) -> String? {+        // Match <meta charset="..." or <meta charset=...+        let lowered = text.lowercased()+        guard let range = lowered.range(of: "charset") else { return nil }+        var idx = range.upperBound+        // Skip whitespace+        while idx < lowered.endIndex, lowered[idx] == " " || lowered[idx] == "\t" { idx = lowered.index(after: idx) }+        guard idx < lowered.endIndex, lowered[idx] == "=" else { return nil }+        idx = lowered.index(after: idx)+        while idx < lowered.endIndex, lowered[idx] == " " || lowered[idx] == "\t" { idx = lowered.index(after: idx) }+        guard idx < lowered.endIndex else { return nil }+        let quote = lowered[idx]+        if quote == "\"" || quote == "'" {+            idx = lowered.index(after: idx)+            guard let end = lowered[idx...].firstIndex(of: quote) else { return nil }+            return String(text[idx..<end])+        } else {+            let start = idx+            while idx < lowered.endIndex, lowered[idx] != " ", lowered[idx] != ";", lowered[idx] != "\"", lowered[idx] != "'", lowered[idx] != ">" {+                idx = lowered.index(after: idx)+            }+            return String(text[start..<idx])+        }+    }++    private func encodingForName(_ name: String) -> String.Encoding? {+        let lower = name.lowercased().trimmingCharacters(in: .whitespaces)+        switch lower {+        case "utf-8", "utf8": return .utf8+        case "iso-8859-1", "latin1", "latin-1": return .isoLatin1+        case "windows-1252", "cp1252": return .windowsCP1252+        case "utf-16", "utf16": return .utf16+        case "ascii", "us-ascii": return .ascii+        default:+            let cf = CFStringConvertIANACharSetNameToEncoding(lower as CFString)+            guard cf != kCFStringEncodingInvalidId else { return nil }+            let ns = CFStringConvertEncodingToNSStringEncoding(cf)+            return String.Encoding(rawValue: ns)+        }+    }++    // MARK: - HTML parsing++    private func parse(_ text: String) -> ScannedHeadMetadata {+        var title: String? = nil+        var canonicalCandidates: [String] = []+        var index = text.startIndex++        while index < text.endIndex {+            // Look for '<'+            guard let tagStart = text[index...].firstIndex(of: "<") else { break }+            index = text.index(after: tagStart)+            guard index < text.endIndex else { break }++            // Check for end tag </head>+            if text[index] == "/" {+                let afterSlash = text.index(after: index)+                if afterSlash < text.endIndex {+                    let remaining = text[afterSlash...]+                    if remaining.prefix(4).lowercased() == "head" {+                        break // Stop at </head>+                    }+                }+                // Skip this closing tag+                if let close = text[index...].firstIndex(of: ">") {+                    index = text.index(after: close)+                }+                continue+            }++            // Check for comment+            if text[index...].hasPrefix("!--") {+                if let end = text[index...].range(of: "-->") {+                    index = end.upperBound+                } else { break }+                continue+            }++            // Parse tag name+            let tagNameStart = index+            while index < text.endIndex, text[index] != " ", text[index] != "\t", text[index] != "\n", text[index] != "\r", text[index] != ">", text[index] != "/" {+                index = text.index(after: index)+            }+            let tagName = String(text[tagNameStart..<index]).lowercased()++            // Parse attributes+            let attrs = parseAttributes(text: text, from: &index)++            if tagName == "title", title == nil {+                // Extract text content until </title>+                if let titleContent = extractTagContent(text: text, from: &index, closingTag: "title") {+                    let decoded = decodeEntities(titleContent)+                    if !decoded.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+                        title = decoded+                    }+                }+            } else if tagName == "link" {+                // Check if rel contains "canonical" (whitespace-tokenized, case-insensitive)+                if let rel = attrs["rel"] {+                    let tokens = rel.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }+                    if tokens.contains("canonical"), let href = attrs["href"] {+                        canonicalCandidates.append(href)+                    }+                }+            }+        }++        return ScannedHeadMetadata(title: title, canonicalCandidates: canonicalCandidates)+    }++    private func parseAttributes(text: String, from index: inout String.Index) -> [String: String] {+        var attrs: [String: String] = [:]+        while index < text.endIndex {+            // Skip whitespace+            while index < text.endIndex, text[index].isWhitespace { index = text.index(after: index) }+            guard index < text.endIndex else { break }+            if text[index] == ">" || text[index] == "/" {+                if text[index] == "/" {+                    index = text.index(after: index)+                    if index < text.endIndex, text[index] == ">" { index = text.index(after: index) }+                } else {+                    index = text.index(after: index)+                }+                return attrs+            }+            // Parse attribute name+            let nameStart = index+            while index < text.endIndex, text[index] != "=", text[index] != " ", text[index] != "\t", text[index] != "\n", text[index] != ">", text[index] != "/" {+                index = text.index(after: index)+            }+            let attrName = String(text[nameStart..<index]).lowercased()+            guard index < text.endIndex else { break }++            if text[index] == "=" {+                index = text.index(after: index)+                guard index < text.endIndex else { break }+                let value: String+                if text[index] == "\"" || text[index] == "'" {+                    let quote = text[index]+                    index = text.index(after: index)+                    let valueStart = index+                    while index < text.endIndex, text[index] != quote {+                        index = text.index(after: index)+                    }+                    value = String(text[valueStart..<index])+                    if index < text.endIndex { index = text.index(after: index) }+                } else {+                    // Unquoted value — stop at whitespace or >+                    let valueStart = index+                    while index < text.endIndex, !text[index].isWhitespace, text[index] != ">" {+                        index = text.index(after: index)+                    }+                    value = String(text[valueStart..<index])+                }+                if !attrName.isEmpty { attrs[attrName] = value }+            } else {+                // Boolean attribute (no value)+                if !attrName.isEmpty { attrs[attrName] = "" }+            }+        }+        return attrs+    }++    private func extractTagContent(text: String, from index: inout String.Index, closingTag: String) -> String? {+        let searchPattern = "</\(closingTag)"+        var content = ""+        while index < text.endIndex {+            if text[index] == "<" {+                // Check for closing tag+                let remaining = text[index...]+                let prefix = String(remaining.prefix(closingTag.count + 2)).lowercased()+                if prefix == searchPattern.lowercased() {+                    // Skip past >+                    if let close = text[index...].firstIndex(of: ">") {+                        index = text.index(after: close)+                    }+                    return content+                }+            }+            content.append(text[index])+            index = text.index(after: index)+        }+        return content.isEmpty ? nil : content+    }++    // MARK: - Entity decoding++    private func decodeEntities(_ text: String) -> String {+        var result = ""+        var index = text.startIndex+        while index < text.endIndex {+            if text[index] == "&" {+                let afterAmp = text.index(after: index)+                if afterAmp < text.endIndex, let semicolonIdx = text[afterAmp...].firstIndex(of: ";") {+                    let entityContent = String(text[afterAmp..<semicolonIdx])+                    if let decoded = decodeEntity(entityContent) {+                        result.append(decoded)+                        index = text.index(after: semicolonIdx)+                        continue+                    }+                }+                // Not a valid entity — keep literal+                result.append("&")+                index = afterAmp+            } else {+                result.append(text[index])+                index = text.index(after: index)+            }+        }+        return result+    }++    private func decodeEntity(_ content: String) -> Character? {+        // Named entities+        switch content {+        case "amp": return "&"+        case "lt": return "<"+        case "gt": return ">"+        case "quot": return "\""+        case "apos": return "'"+        default: break+        }+        // Numeric entities+        if content.hasPrefix("#x") || content.hasPrefix("#X") {+            let hex = String(content.dropFirst(2))+            if let codePoint = UInt32(hex, radix: 16), let scalar = Unicode.Scalar(codePoint) {+                return Character(scalar)+            }+        } else if content.hasPrefix("#") {+            let decimal = String(content.dropFirst(1))+            if let codePoint = UInt32(decimal), let scalar = Unicode.Scalar(codePoint) {+                return Character(scalar)+            }+        }+        // Unknown named entity — return nil to keep literal+        return nil+    }+}++// MARK: - Fetch errors++/// Contextual error for page-title acquisition failures.+public struct PageTitleFetchError: Error, Sendable {+    public let context: String+    public let underlying: (any Error)?++    public init(context: String, underlying: (any Error)? = nil) {+        self.context = context+        self.underlying = underlying+    }+}++// MARK: - Bounded page title fetcher++/// Production implementation of `PageTitleFetching` that uses an ephemeral URLSession+/// with a streaming delegate to enforce a hard 65,536-byte boundary and a monotonic+/// 3-second deadline across all redirects and body reads.+/// Cancellation of the parent Swift Task cancels the underlying URLSession work.+public final class BoundedPageTitleFetcher: PageTitleFetching, @unchecked Sendable {+    /// The hard total deadline in seconds covering all redirects and body reads.+    public static let totalDeadline: Duration = .seconds(3)+    /// Maximum accepted response body bytes. Byte 65,537 causes cancellation.+    public static let maxResponseBytes: Int = 65_536++    public var deadline: Duration { Self.totalDeadline }+    public var maxBodyBytes: Int { Self.maxResponseBytes }++    /// Optional URLProtocol classes for deterministic network tests.+    private let protocolClasses: [AnyClass]?+    private let deadlineDuration: Duration++    /// Create a production fetcher with the fixed acquisition deadline.+    public init() {+        self.protocolClasses = nil+        self.deadlineDuration = Self.totalDeadline+    }++    /// Create a fetcher with injected URLProtocol classes for testing.+    public init(protocolClasses: [AnyClass]) {+        self.protocolClasses = protocolClasses+        self.deadlineDuration = Self.totalDeadline+    }++    /// Test-only seam for proving that one deadline bounds the complete request.+    init(protocolClasses: [AnyClass], deadline: Duration) {+        self.protocolClasses = protocolClasses+        self.deadlineDuration = deadline+    }++    /// Check if a MIME type is acceptable for title extraction.+    /// Accepts text/html, application/xhtml+xml, or nil (absent).+    public static func isAcceptableMIME(_ mime: String?) -> Bool {+        guard let mime = mime else { return true }+        let lower = mime.lowercased().trimmingCharacters(in: .whitespaces)+        let base = lower.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces) ?? lower+        return base == "text/html" || base == "application/xhtml+xml"+    }++    /// Extract charset from Content-Type header value.+    public static func extractCharset(fromContentType contentType: String?) -> String? {+        guard let ct = contentType else { return nil }+        let parts = ct.components(separatedBy: ";")+        for part in parts.dropFirst() {+            let trimmed = part.trimmingCharacters(in: .whitespaces)+            if trimmed.lowercased().hasPrefix("charset=") {+                var value = String(trimmed.dropFirst("charset=".count))+                value = value.trimmingCharacters(in: .whitespaces)+                if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {+                    value = String(value.dropFirst().dropLast())+                }+                return value.isEmpty ? nil : value+            }+        }+        return nil+    }++    public func fetch(url: String) async throws -> FetchedPageMetadata? {+        guard let components = URLComponents(string: url),+              let scheme = components.scheme?.lowercased(),+              (scheme == "http" || scheme == "https"),+              let host = components.host,+              !host.isEmpty,+              let requestURL = components.url else {+            throw PageTitleFetchError(context: "Invalid HTTP(S) URL for page-title fetch")+        }++        let configuration = URLSessionConfiguration.ephemeral+        configuration.httpCookieAcceptPolicy = .never+        configuration.httpShouldSetCookies = false+        configuration.urlCredentialStorage = nil+        configuration.urlCache = nil+        configuration.httpCookieStorage = nil+        configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData+        // One monotonic 3-second deadline across all redirects and body reads.+        configuration.timeoutIntervalForResource = 3.0+        configuration.timeoutIntervalForRequest = 3.0++        if let protocols = protocolClasses {+            configuration.protocolClasses = protocols+        }++        let delegate = BoundedFetchDelegate(maxBytes: Self.maxResponseBytes)+        let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)++        // Ensure session is invalidated whether we succeed or fail+        defer { session.invalidateAndCancel() }++        var request = URLRequest(url: requestURL)+        request.httpMethod = "GET"+        request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")++        // Use delegate-based streaming to enforce byte boundary before accepting data.+        // The delegate accumulates data and cancels the task when maxBytes is exceeded.+        let dataTask = session.dataTask(with: request)+        delegate.task = dataTask+        dataTask.resume()++        // Task.sleep uses a monotonic clock. One timer bounds redirects and body reads;+        // URLSession's resource timeout remains a second defensive boundary.+        let deadlineTask = Task {+            do {+                try await Task.sleep(for: deadlineDuration)+                delegate.cancelForDeadline()+            } catch is CancellationError {+                // Normal completion cancels the timer.+            } catch {+                delegate.cancelForDeadline()+            }+        }+        defer { deadlineTask.cancel() }++        let result: (data: Data, response: URLResponse?, finalURL: URL?)+        do {+            result = try await withTaskCancellationHandler {+                try await delegate.waitForCompletion()+            } onCancel: {+                delegate.cancelForCaller()+            }+        } catch is CancellationError {+            throw CancellationError()+        } catch {+            throw PageTitleFetchError(context: "Page-title fetch failed", underlying: error)+        }++        // Validate HTTP response+        guard let httpResponse = result.response as? HTTPURLResponse else {+            return nil // Non-HTTP response+        }++        // 2xx only+        guard (200..<300).contains(httpResponse.statusCode) else {+            return nil+        }++        // MIME check+        let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")+        let mimeBase = contentType?.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces)+        guard Self.isAcceptableMIME(mimeBase) else {+            return nil+        }++        // Data already bounded by delegate to maxResponseBytes+        let boundedData = result.data++        // Extract charset from HTTP Content-Type+        let httpCharset = Self.extractCharset(fromContentType: contentType)++        // Scan the bounded data for title and canonical candidates+        let scanner = HeadMetadataScanner()+        let scanned = scanner.scan(boundedData, httpCharset: httpCharset)++        // Title: semantic trimmed text+        let title: String?+        if let rawTitle = scanned.title {+            let trimmed = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)+            title = trimmed.isEmpty ? nil : trimmed+        } else {+            title = nil+        }++        // Final URL after redirects+        let finalURLString = result.finalURL?.absoluteString++        return FetchedPageMetadata(+            title: title,+            canonicalCandidates: scanned.canonicalCandidates,+            finalURL: finalURLString+        )+    }+}++// MARK: - URLSession delegate for bounded streaming fetch++/// Streaming delegate that accumulates body data up to maxBytes, cancels on byte+/// maxBytes+1, rejects authentication challenges, and tracks redirect final URL.+/// Uses an async continuation to bridge delegate callbacks to structured concurrency.+private final class BoundedFetchDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable {+    private enum StopReason {+        case none+        case byteLimit+        case callerCancellation+        case deadline+    }++    private let lock = NSLock()+    private let maxBytes: Int+    private var accumulatedData = Data()+    private var receivedBytes = 0+    private var stopReason = StopReason.none+    private var _finalURL: URL?+    private var _response: URLResponse?+    private var _error: (any Error)?+    private var _completed = false+    private var continuation: CheckedContinuation<(data: Data, response: URLResponse?, finalURL: URL?), any Error>?+    weak var task: URLSessionDataTask?++    init(maxBytes: Int) {+        self.maxBytes = maxBytes+        super.init()+    }++    func cancelForCaller() {+        cancel(reason: .callerCancellation)+    }++    func cancelForDeadline() {+        cancel(reason: .deadline)+    }++    private func cancel(reason: StopReason) {+        let taskToCancel = lock.withLock { () -> URLSessionDataTask? in+            guard !_completed else { return nil }+            // An explicit caller cancellation or deadline always outranks an+            // internal byte-limit stop that is still awaiting completion.+            stopReason = reason+            return task+        }+        taskToCancel?.cancel()+    }++    /// Await task completion; propagates errors and cancellation.+    func waitForCompletion() async throws -> (data: Data, response: URLResponse?, finalURL: URL?) {+        try await withCheckedThrowingContinuation { cont in+            lock.withLock {+                if _completed {+                    if let error = _error {+                        cont.resume(throwing: error)+                    } else {+                        cont.resume(returning: (accumulatedData, _response, _finalURL))+                    }+                } else {+                    continuation = cont+                }+            }+        }+    }++    private func finish(error: (any Error)? = nil) {+        lock.withLock {+            guard !_completed else { return }+            _completed = true+            _error = error+            if let cont = continuation {+                continuation = nil+                if let error = error {+                    cont.resume(throwing: error)+                } else {+                    cont.resume(returning: (accumulatedData, _response, _finalURL))+                }+            }+        }+    }++    // MARK: - URLSessionDataDelegate++    func urlSession(+        _ session: URLSession,+        task: URLSessionTask,+        willPerformHTTPRedirection response: HTTPURLResponse,+        newRequest request: URLRequest,+        completionHandler: @escaping (URLRequest?) -> Void+    ) {+        // Allow redirects but track the final URL+        lock.withLock { _finalURL = request.url }+        completionHandler(request)+    }++    func urlSession(+        _ session: URLSession,+        dataTask: URLSessionDataTask,+        didReceive response: URLResponse,+        completionHandler: @escaping (URLSession.ResponseDisposition) -> Void+    ) {+        // Track final URL from response+        lock.withLock {+            _response = response+            if let url = dataTask.currentRequest?.url ?? response.url {+                _finalURL = url+            }+        }+        completionHandler(.allow)+    }++    func urlSession(+        _ session: URLSession,+        dataTask: URLSessionDataTask,+        didReceive data: Data+    ) {+        let shouldCancel = lock.withLock { () -> Bool in+            guard case .none = stopReason else { return false }+            let remaining = maxBytes - receivedBytes+            guard remaining > 0 else {+                // Receiving any data after byte 65,536 proves the response is+                // over budget; reject it before accepting another byte.+                stopReason = .byteLimit+                return true+            }+            if data.count <= remaining {+                accumulatedData.append(data)+                receivedBytes += data.count+                return false+            }++            accumulatedData.append(data.prefix(remaining))+            receivedBytes += remaining+            stopReason = .byteLimit+            return true+        }+        if shouldCancel {+            dataTask.cancel()+        }+    }++    func urlSession(+        _ session: URLSession,+        task: URLSessionTask,+        didCompleteWithError error: (any Error)?+    ) {+        let reason = lock.withLock { stopReason }+        switch reason {+        case .callerCancellation:+            finish(error: CancellationError())+        case .deadline:+            finish(error: PageTitleFetchError(context: "Page-title fetch exceeded the total deadline"))+        case .byteLimit:+            // The scanner receives exactly the accepted prefix. The transfer was+            // cancelled before byte 65,537 became part of the input.+            finish()+        case .none:+            if let error = error as? URLError, error.code == .cancelled {+                finish(error: CancellationError())+            } else if let error {+                finish(error: PageTitleFetchError(context: "Network request failed", underlying: error))+            } else {+                finish()+            }+        }+    }++    func urlSession(+        _ session: URLSession,+        task: URLSessionTask,+        didReceive challenge: URLAuthenticationChallenge,+        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void+    ) {+        // Reject all authentication challenges except server trust+        let protectionSpace = challenge.protectionSpace+        if protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {+            // Allow server trust validation (TLS) to proceed normally+            if let trust = protectionSpace.serverTrust {+                completionHandler(.useCredential, URLCredential(trust: trust))+            } else {+                completionHandler(.performDefaultHandling, nil)+            }+        } else {+            // Reject HTTP auth challenges (Basic, Digest, etc.)+            completionHandler(.cancelAuthenticationChallenge, nil)+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/RepositoryClock.swift Added +38 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RepositoryClock.swift b/Packages/AsterismCore/Sources/AsterismCore/RepositoryClock.swiftnew file mode 100644index 0000000..4599f48--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RepositoryClock.swift@@ -0,0 +1,38 @@+import Foundation++public enum MillisecondInstant {+    public static func quantize(_ date: Date) -> Date {+        let scaled = date.timeIntervalSince1970 * 1_000+        // Date stores binary floating-point seconds. Values produced from an+        // exact millisecond can land infinitesimally below its integer after+        // multiplication; snap only that representation noise before floor.+        let nearestInteger = scaled.rounded()+        let normalized = abs(scaled - nearestInteger) < 0.000_001 ? nearestInteger : floor(scaled)+        return Date(timeIntervalSince1970: normalized / 1_000)+    }++    public static func isQuantized(_ date: Date) -> Bool {+        let scaled = date.timeIntervalSince1970 * 1_000+        return abs(scaled - scaled.rounded()) < 0.001+    }+}++public struct SystemRepositoryClock: RepositoryClock {+    public init() {}++    public func now() -> Date {+        MillisecondInstant.quantize(Date())+    }+}++public struct FixedRepositoryClock: RepositoryClock {+    private let instant: Date++    public init(_ instant: Date) {+        self.instant = MillisecondInstant.quantize(instant)+    }++    public func now() -> Date {+        instant+    }+}
Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift Added +62 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift b/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swiftnew file mode 100644index 0000000..3ed6050--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift@@ -0,0 +1,62 @@+import Foundation++public struct CaptureDraft: Equatable, Sendable {+    public let captureTitle: String+    public let captureTitleSource: CaptureTitleSource+    public let rawURLString: String+    public let canonicalURLString: String?+    public let note: String+    public let rating: Rating?++    public init(+        captureTitle: String,+        captureTitleSource: CaptureTitleSource,+        rawURLString: String,+        canonicalURLString: String? = nil,+        note: String = "",+        rating: Rating? = nil+    ) {+        self.captureTitle = captureTitle+        self.captureTitleSource = captureTitleSource+        self.rawURLString = rawURLString+        self.canonicalURLString = canonicalURLString+        self.note = note+        self.rating = rating+    }+}++public enum CaptureSiteStatus: Equatable, Sendable {+    case newSite+    case existingUntaught+    case existingTaught+}++public struct NewWorkDraft: Equatable, Sendable {+    public let displayTitle: String+    public let hostname: String++    public init(displayTitle: String, hostname: String) {+        self.displayTitle = displayTitle+        self.hostname = hostname+    }+}++public struct WorkMetadataDraft: Equatable, Sendable {+    public let displayTitle: String+    public let type: WorkType+    public let genreTags: [String]+    public let genericNotes: String++    public init(displayTitle: String, type: WorkType, genreTags: [String], genericNotes: String) {+        self.displayTitle = displayTitle+        self.type = type+        self.genreTags = genreTags+        self.genericNotes = genericNotes+    }+}++public enum WorkDestination: Equatable, Sendable {+    case existing(UUID)+    case newWork(displayTitle: String)+    case unattached+}
Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift Added +265 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift b/Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swiftnew file mode 100644index 0000000..c697ec5--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift@@ -0,0 +1,265 @@+import Foundation++// MARK: - Testable abstraction for extension item/provider loading++/// Protocol abstracting an item provider's loading capability for testability.+/// In production, NSItemProvider conforms; in tests, replaced with fakes.+public protocol ItemProviderLoading: Sendable {+    var hasURL: Bool { get }+    var hasPropertyList: Bool { get }+    /// Load URL. Returns a cancellable Progress and delivers result via callback.+    func loadURL(completion: @escaping @Sendable (URL?, (any Error)?) -> Void) -> Progress+    /// Load property list. Returns a cancellable Progress and delivers result via callback.+    func loadPropertyList(completion: @escaping @Sendable ([String: Any]?, (any Error)?) -> Void) -> Progress+}++/// Protocol abstracting an extension item for testability.+public protocol ExtensionItemProviding: Sendable {+    var attributedTitleString: String? { get }+    var providers: [any ItemProviderLoading] { get }+}++// MARK: - Pure selection logic (testable without UIKit/NSExtensionItem)++/// Extracted share payload selection result, usable by the ShareInputAdapter+/// and testable independently.+public struct SharePayloadSelection: Sendable {+    public let providerURL: String?+    public let hostTitle: String?+    public let safari: SafariPageMetadata?+}++/// Key used to find Safari JavaScript preprocessing results in a property list.+public let safariPreprocessingResultsKey = "NSExtensionJavaScriptPreprocessingResultsKey"++/// Pure extraction logic operating on the testable abstractions.+/// Walks items in source order, chooses first usable URL, derives nonblank title,+/// independently decodes Safari property-list metadata.+/// Stores/cancels outstanding Progress objects.+/// Call from @MainActor in production (async); testable with synchronous fakes.+public final class SharePayloadExtractor: @unchecked Sendable {+    private let lock = NSLock()+    private var outstandingLoads: [UUID: @Sendable () -> Void] = [:]++    public init() {}++    /// Cancel all outstanding provider loads and resume their awaiters with cancellation.+    public func cancelOutstanding() {+        let cancellations = lock.withLock { () -> [@Sendable () -> Void] in+            let values = Array(outstandingLoads.values)+            outstandingLoads.removeAll()+            return values+        }+        cancellations.forEach { $0() }+    }++    /// Extract a payload from generic ExtensionItemProviding conformers.+    /// Preserves source order, first usable URL wins, continues after failures.+    public func extract(from items: [any ExtensionItemProviding]) async throws -> SharePayload {+        try await withTaskCancellationHandler {+            try await extractItems(from: items)+        } onCancel: {+            cancelOutstanding()+        }+    }++    private func extractItems(from items: [any ExtensionItemProviding]) async throws -> SharePayload {+        var providerURL: String?+        var hostTitle: String?+        var safari: SafariPageMetadata?++        for extensionItem in items {+            // Derive host title from attributedTitle (nonblank only)+            if hostTitle == nil, let attrTitle = extensionItem.attributedTitleString {+                let text = attrTitle.trimmingCharacters(in: .whitespacesAndNewlines)+                if !text.isEmpty {+                    hostTitle = attrTitle+                }+            }++            let attachments = extensionItem.providers++            for attachment in attachments {+                // Extract URL provider (first usable)+                if providerURL == nil, attachment.hasURL {+                    do {+                        let url = try await loadURLCancellable(from: attachment)+                        if let url = url {+                            providerURL = url.absoluteString+                        }+                    } catch is CancellationError {+                        cancelOutstanding()+                        throw CancellationError()+                    } catch {+                        // Continue to next attachment+                    }+                }++                // Independently decode Safari property-list metadata+                if safari == nil, attachment.hasPropertyList {+                    do {+                        let dict = try await loadPropertyListCancellable(from: attachment)+                        if let dict = dict,+                           let jsResults = dict[safariPreprocessingResultsKey] as? [String: Any] {+                            safari = Self.decodeSafariMetadata(from: jsResults)+                        }+                    } catch is CancellationError {+                        cancelOutstanding()+                        throw CancellationError()+                    } catch {+                        // Continue — Safari metadata is optional+                    }+                }+            }+        }++        // Safari's documented webpage-preprocessing payload may be the only+        // attachment. location.href is authoritative for Safari captures, so+        // also use it to satisfy SharePayload's provider fallback field.+        guard let url = providerURL ?? safari?.locationHref else {+            throw SharePayloadExtractionError.noUsableURL+        }++        return SharePayload(providerURL: url, hostTitle: hostTitle, safari: safari)+    }++    // MARK: - Private++    private func loadURLCancellable(from provider: any ItemProviderLoading) async throws -> URL? {+        try Task.checkCancellation()+        let load = CancellableProviderLoad<URL?>()+        let id = register { load.cancel() }+        defer { unregister(id) }+        return try await load.run { completion in+            provider.loadURL { url, error in+                if let error {+                    completion(.failure(Self.mapProviderError(error)))+                } else {+                    completion(.success(url))+                }+            }+        }+    }++    private func loadPropertyListCancellable(from provider: any ItemProviderLoading) async throws -> [String: Any]? {+        try Task.checkCancellation()+        let load = CancellableProviderLoad<PropertyListBox>()+        let id = register { load.cancel() }+        defer { unregister(id) }+        return try await load.run { completion in+            provider.loadPropertyList { dictionary, error in+                if let error {+                    completion(.failure(Self.mapProviderError(error)))+                } else {+                    completion(.success(PropertyListBox(dictionary)))+                }+            }+        }.value+    }++    private func register(_ cancellation: @escaping @Sendable () -> Void) -> UUID {+        let id = UUID()+        lock.withLock { outstandingLoads[id] = cancellation }+        return id+    }++    private func unregister(_ id: UUID) {+        _ = lock.withLock { outstandingLoads.removeValue(forKey: id) }+    }++    private static func mapProviderError(_ error: any Error) -> any Error {+        (error as NSError).code == NSUserCancelledError ? CancellationError() : error+    }++    private static func decodeSafariMetadata(from dict: [String: Any]) -> SafariPageMetadata? {+        guard let locationHref = dict["locationHref"] as? String, !locationHref.isEmpty else {+            return nil+        }+        let title = dict["title"] as? String+        let canonicalCandidates = (dict["canonicalCandidates"] as? [String]) ?? []+        return SafariPageMetadata(+            title: title,+            locationHref: locationHref,+            canonicalCandidates: canonicalCandidates+        )+    }+}++/// Errors from the payload extraction logic.+public enum SharePayloadExtractionError: Error, Sendable {+    case noUsableURL+    case cancelled+}++/// Wrapper to transfer property-list dictionaries across concurrency boundaries.+/// Property-list values are always immutable Foundation types, making this safe.+private struct PropertyListBox: @unchecked Sendable {+    let value: [String: Any]?+    init(_ value: [String: Any]?) { self.value = value }+}++/// Bridges callback-based item-provider loads into cancellation-safe async work.+/// Completion and cancellation race through one lock so the continuation resumes once.+private final class CancellableProviderLoad<Value: Sendable>: @unchecked Sendable {+    private let lock = NSLock()+    private var continuation: CheckedContinuation<Value, any Error>?+    private var progress: Progress?+    private var isFinished = false++    func run(+        _ start: (@escaping @Sendable (Result<Value, any Error>) -> Void) -> Progress+    ) async throws -> Value {+        try await withTaskCancellationHandler {+            try await withCheckedThrowingContinuation { continuation in+                let shouldStart = lock.withLock { () -> Bool in+                    guard !isFinished else { return false }+                    self.continuation = continuation+                    return true+                }+                guard shouldStart else {+                    continuation.resume(throwing: CancellationError())+                    return+                }++                let progress = start { [weak self] result in+                    self?.finish(result)+                }+                let shouldCancel = lock.withLock { () -> Bool in+                    guard !isFinished else { return true }+                    self.progress = progress+                    return false+                }+                if shouldCancel {+                    progress.cancel()+                }+            }+        } onCancel: {+            cancel()+        }+    }++    func cancel() {+        let pending = lock.withLock { () -> (Progress?, CheckedContinuation<Value, any Error>?) in+            guard !isFinished else { return (nil, nil) }+            isFinished = true+            let values = (progress, continuation)+            progress = nil+            continuation = nil+            return values+        }+        pending.0?.cancel()+        pending.1?.resume(throwing: CancellationError())+    }++    private func finish(_ result: Result<Value, any Error>) {+        let pending = lock.withLock { () -> CheckedContinuation<Value, any Error>? in+            guard !isFinished else { return nil }+            isFinished = true+            progress = nil+            let value = continuation+            continuation = nil+            return value+        }+        pending?.resume(with: result)+    }+}
Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift Added +256 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swiftnew file mode 100644index 0000000..822497b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift@@ -0,0 +1,256 @@+import Foundation++// MARK: - Sendable payloads from the extension adapter++/// The Sendable value produced by the main-actor ShareInputAdapter after+/// extracting provider and Safari data from NSExtensionItem attachments.+public struct SharePayload: Sendable, Equatable {+    /// The verbatim provider URL string from the first `UTType.url` attachment.+    public let providerURL: String+    /// The host-supplied title from `NSExtensionItem.attributedTitle` when nonblank.+    public let hostTitle: String?+    /// Safari preprocessing result decoded independently from a property-list attachment.+    public let safari: SafariPageMetadata?++    public init(providerURL: String, hostTitle: String? = nil, safari: SafariPageMetadata? = nil) {+        self.providerURL = providerURL+        self.hostTitle = hostTitle+        self.safari = safari+    }+}++/// Decoded Safari preprocessing JavaScript result.+public struct SafariPageMetadata: Sendable, Equatable {+    /// The `document.title` at share time; may be blank.+    public let title: String?+    /// `location.href` — the effective page URL at share time.+    public let locationHref: String+    /// All `<link rel="canonical">` href values in document order.+    public let canonicalCandidates: [String]++    public init(title: String?, locationHref: String, canonicalCandidates: [String] = []) {+        self.title = title+        self.locationHref = locationHref+        self.canonicalCandidates = canonicalCandidates+    }+}++// MARK: - Capture preparation result++/// The result of preparing a SharePayload for capture UI presentation.+public struct CapturePreparation: Sendable, Equatable {+    /// The selected raw URL (Safari location.href or provider URL).+    public let rawURL: String+    /// The winning title, if one was resolved without manual input.+    public let resolvedTitle: String?+    /// The source of the resolved title, or `.manual` if no title could be resolved.+    public let titleSource: CaptureTitleSource+    /// The first valid canonical URL resolved from candidates, if any.+    public let canonicalURL: String?+    /// Site existence status for the capture sheet's first-site messaging.+    public let siteStatus: CaptureSiteStatus+}++// MARK: - Preparation error++/// Error thrown when capture preparation fails in a way that should be surfaced to the UI.+public struct CapturePreparationError: Error, Sendable {+    public let userMessage: String+    public let underlying: (any Error)?++    public init(userMessage: String, underlying: (any Error)? = nil) {+        self.userMessage = userMessage+        self.underlying = underlying+    }+}++// MARK: - Page title fetcher protocol (test seam)++/// Boundary for network metadata acquisition. Tests inject a controlled+/// implementation; production uses an ephemeral URLSession fetcher.+public protocol PageTitleFetching: Sendable {+    /// Fetch the page title and canonical candidates from a non-Safari URL.+    /// Returns nil title on failure (triggers manual fallback).+    func fetch(url: String) async throws -> FetchedPageMetadata?+}++/// Result from a successful network title fetch.+public struct FetchedPageMetadata: Sendable, Equatable {+    /// Decoded `<title>` with leading/trailing Unicode whitespace removed.+    public let title: String?+    /// Canonical link candidates in document order.+    public let canonicalCandidates: [String]+    /// The final response URL after redirects (used as base for relative canonicals).+    public let finalURL: String?++    public init(title: String?, canonicalCandidates: [String] = [], finalURL: String? = nil) {+        self.title = title+        self.canonicalCandidates = canonicalCandidates+        self.finalURL = finalURL+    }+}++// MARK: - CaptureCoordinator++/// Coordinates share payload preparation and save for the extension.+/// Owns title resolution logic, canonical validation, and Site-status lookup.+public actor CaptureCoordinator {+    private let repository: LibraryRepository+    private let fetcher: (any PageTitleFetching)?++    public init(repository: LibraryRepository, fetcher: (any PageTitleFetching)? = nil) {+        self.repository = repository+        self.fetcher = fetcher+    }++    /// Prepare a SharePayload for capture UI. Determines raw URL, title,+    /// canonical URL, and site status.+    /// Throws `CapturePreparationError` when URL is invalid or site status cannot be determined.+    public func prepare(_ payload: SharePayload) async throws -> CapturePreparation {+        // 1. Select raw URL: Safari location.href when valid HTTP(S), else provider URL+        let rawURL: String+        let useSafariURL: Bool+        if let safari = payload.safari, Self.isValidHTTPURL(safari.locationHref) {+            rawURL = safari.locationHref+            useSafariURL = true+        } else {+            rawURL = payload.providerURL+            useSafariURL = false+        }++        // Validate that we have a usable HTTP(S) URL (requirement 2.1)+        guard Self.isValidHTTPURL(rawURL) else {+            throw CapturePreparationError(+                userMessage: "A page URL is required. The shared content does not contain a usable web address."+            )+        }++        // 2. Resolve canonical from Safari candidates (only when Safari metadata present and URL valid)+        let canonicalURL: String?+        if useSafariURL, let safari = payload.safari {+            canonicalURL = Self.resolveFirstValidCanonical(+                candidates: safari.canonicalCandidates,+                baseURL: safari.locationHref+            )+        } else if !useSafariURL, payload.safari == nil {+            // Non-Safari: canonicals come from fetcher if invoked+            canonicalURL = nil // Will be set below if fetcher returns candidates+        } else {+            canonicalURL = nil+        }++        // 3. Title resolution — evidence and title selection are separate+        let resolvedTitle: String?+        let titleSource: CaptureTitleSource+        var fetchedCanonical: String? = nil++        let effectiveHostTitle = payload.hostTitle.flatMap {+            $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0+        }++        if let hostTitle = effectiveHostTitle {+            // Host title takes precedence regardless of Safari presence+            // Preserved verbatim (trim only for validation per design)+            resolvedTitle = hostTitle+            titleSource = .host+        } else if let safari = payload.safari {+            // Safari is available — use its title or fall to manual. Never network fetch.+            if let safariTitle = safari.title, !safariTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+                // Preserved verbatim per design+                resolvedTitle = safariTitle+                titleSource = .safariDocument+            } else {+                // Blank/nil Safari title → manual, no network+                resolvedTitle = nil+                titleSource = .manual+            }+        } else {+            // No Safari — attempt network fetch if fetcher available+            if let fetcher = fetcher, Self.isValidHTTPURL(rawURL) {+                do {+                    if let metadata = try await fetcher.fetch(url: rawURL) {+                        if let title = metadata.title,+                           !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+                            // Fetched title uses semantic trimmed text per design+                            resolvedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)+                            titleSource = .networkFetch+                        } else {+                            resolvedTitle = nil+                            titleSource = .manual+                        }+                        // Use fetched canonicals resolved against final response URL (not raw URL)+                        let canonicalBase = metadata.finalURL ?? rawURL+                        fetchedCanonical = Self.resolveFirstValidCanonical(+                            candidates: metadata.canonicalCandidates,+                            baseURL: canonicalBase+                        )+                    } else {+                        resolvedTitle = nil+                        titleSource = .manual+                    }+                } catch {+                    resolvedTitle = nil+                    titleSource = .manual+                }+            } else {+                resolvedTitle = nil+                titleSource = .manual+            }+        }++        // 4. Determine site status — errors are surfaced, not swallowed+        let siteStatus: CaptureSiteStatus+        do {+            siteStatus = try await repository.siteStatus(forRawURL: rawURL)+        } catch {+            throw CapturePreparationError(+                userMessage: "Unable to check site status. The library may be unavailable.",+                underlying: error+            )+        }++        // 5. Resolve final canonical (Safari canonical or fetched canonical)+        let finalCanonical = canonicalURL ?? fetchedCanonical++        return CapturePreparation(+            rawURL: rawURL,+            resolvedTitle: resolvedTitle,+            titleSource: titleSource,+            canonicalURL: finalCanonical,+            siteStatus: siteStatus+        )+    }++    // MARK: - Private helpers++    private static func isValidHTTPURL(_ value: String) -> Bool {+        guard let components = URLComponents(string: value),+              let scheme = components.scheme?.lowercased(),+              (scheme == "http" || scheme == "https"),+              let host = components.host, !host.isEmpty else {+            return false+        }+        return true+    }++    /// Resolve canonical candidates against a base URL, returning the first valid HTTP(S) result.+    private static func resolveFirstValidCanonical(candidates: [String], baseURL: String) -> String? {+        guard let base = URL(string: baseURL) else { return nil }+        for candidate in candidates {+            guard !candidate.isEmpty else { continue }+            // Try resolving as potentially relative+            guard let resolved = URL(string: candidate, relativeTo: base) else { continue }+            let absolute = resolved.absoluteURL+            guard let scheme = absolute.scheme?.lowercased(),+                  (scheme == "http" || scheme == "https"),+                  let host = absolute.host, !host.isEmpty else { continue }+            return absolute.absoluteString+        }+        return nil+    }++    /// Save a capture draft through the repository.+    public func save(_ draft: CaptureDraft) async throws -> EntrySnapshot {+        try await repository.capture(draft)+    }+}
Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift Added +126 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swiftnew file mode 100644index 0000000..cb2e84f--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift@@ -0,0 +1,126 @@+import Foundation++public struct EntrySnapshot: Equatable, Sendable {+    public let id: UUID+    public let captureTitle: String+    public let captureTitleSource: CaptureTitleSource+    public let rawURLString: String+    public let canonicalURLString: String?+    public let hostname: String+    public let entryIdentityKey: String+    public let identityKeyVersion: Int+    public let chapterTitle: String?+    public let chapterTitleProvenance: FieldProvenance+    public let note: String+    public let rating: Rating?+    public let firstCapturedAt: Date+    public let lastSharedAt: Date+    public let modifiedAt: Date+    public let workID: UUID?+    public let workAssignmentProvenance: FieldProvenance+    public let intentionallyUnattached: Bool++    public init(+        id: UUID, captureTitle: String, captureTitleSource: CaptureTitleSource,+        rawURLString: String, canonicalURLString: String?, hostname: String,+        entryIdentityKey: String, identityKeyVersion: Int, chapterTitle: String?,+        chapterTitleProvenance: FieldProvenance, note: String, rating: Rating?,+        firstCapturedAt: Date, lastSharedAt: Date, modifiedAt: Date,+        workID: UUID?, workAssignmentProvenance: FieldProvenance, intentionallyUnattached: Bool+    ) {+        self.id = id+        self.captureTitle = captureTitle+        self.captureTitleSource = captureTitleSource+        self.rawURLString = rawURLString+        self.canonicalURLString = canonicalURLString+        self.hostname = hostname+        self.entryIdentityKey = entryIdentityKey+        self.identityKeyVersion = identityKeyVersion+        self.chapterTitle = chapterTitle+        self.chapterTitleProvenance = chapterTitleProvenance+        self.note = note+        self.rating = rating+        self.firstCapturedAt = firstCapturedAt+        self.lastSharedAt = lastSharedAt+        self.modifiedAt = modifiedAt+        self.workID = workID+        self.workAssignmentProvenance = workAssignmentProvenance+        self.intentionallyUnattached = intentionallyUnattached+    }+}++public struct WorkSnapshot: Equatable, Sendable {+    public let id: UUID+    public let displayTitle: String+    public let lastParsedTitle: String?+    public let siteHostname: String+    public let urlIdentity: String?+    public let workURLString: String?+    public let genericNotes: String+    public let type: WorkType+    public let genreTags: [String]+    public let titleProvenance: TitleProvenance+    public let createdAt: Date+    public let modifiedAt: Date+    public let entries: [EntrySnapshot]++    public init(+        id: UUID, displayTitle: String, lastParsedTitle: String?, siteHostname: String,+        urlIdentity: String?, workURLString: String?, genericNotes: String, type: WorkType,+        genreTags: [String], titleProvenance: TitleProvenance, createdAt: Date,+        modifiedAt: Date, entries: [EntrySnapshot]+    ) {+        self.id = id+        self.displayTitle = displayTitle+        self.lastParsedTitle = lastParsedTitle+        self.siteHostname = siteHostname+        self.urlIdentity = urlIdentity+        self.workURLString = workURLString+        self.genericNotes = genericNotes+        self.type = type+        self.genreTags = genreTags+        self.titleProvenance = titleProvenance+        self.createdAt = createdAt+        self.modifiedAt = modifiedAt+        self.entries = entries+    }+}++public struct SiteSnapshot: Equatable, Sendable {+    public let hostname: String+    public let displayName: String+    public let mode: SiteMode+    public let patternIDs: [UUID]+    public let urlIdentityRule: URLIdentityRule?+    public let junkSuffixRule: JunkSuffixRule?+}++public struct TitlePatternSnapshot: Equatable, Sendable {+    public let id: UUID+    public let version: Int+    public let isActive: Bool+    public let createdAt: Date+    public let workAnchor: SegmentRangeSpec+    public let junkAnchors: [SegmentPositionSpec]+    public let siteHostname: String+}++public struct DatedEntryGroup: Equatable, Sendable {+    public let day: Date+    public let entries: [EntrySnapshot]++    public init(day: Date, entries: [EntrySnapshot]) {+        self.day = day+        self.entries = entries+    }+}++public struct WorksSnapshot: Equatable, Sendable {+    public let works: [WorkSnapshot]+    public let unattachedEntries: [EntrySnapshot]++    public init(works: [WorkSnapshot], unattachedEntries: [EntrySnapshot]) {+        self.works = works+        self.unattachedEntries = unattachedEntries+    }+}
Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift Added +109 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swiftnew file mode 100644index 0000000..4877c40--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift@@ -0,0 +1,109 @@+import Foundation++public enum ModelInvariantError: Error, Equatable, Sendable, CustomStringConvertible {+    case nonPositiveVersion(field: String)+    case negativeOffset(field: String)+    case nonPositiveLength(field: String)+    case blankValue(field: String)+    case invalidCombination(field: String)++    public var description: String {+        switch self {+        case .nonPositiveVersion(let field): "\(field) must be positive"+        case .negativeOffset(let field): "\(field) must not be negative"+        case .nonPositiveLength(let field): "\(field) must be positive"+        case .blankValue(let field): "\(field) must not be blank"+        case .invalidCombination(let field): "\(field) contains an invalid field combination"+        }+    }+}++public struct SegmentRangeSpec: Codable, Equatable, Hashable, Sendable {+    public let origin: AnchorOrigin+    public let offset: Int+    public let length: Int++    public init(origin: AnchorOrigin, offset: Int, length: Int) throws {+        guard offset >= 0 else { throw ModelInvariantError.negativeOffset(field: "segment range offset") }+        guard length > 0 else { throw ModelInvariantError.nonPositiveLength(field: "segment range length") }+        self.origin = origin+        self.offset = offset+        self.length = length+    }+}++public struct SegmentPositionSpec: Codable, Equatable, Hashable, Sendable {+    public let origin: AnchorOrigin+    public let offset: Int++    public init(origin: AnchorOrigin, offset: Int) throws {+        guard offset >= 0 else { throw ModelInvariantError.negativeOffset(field: "segment position offset") }+        self.origin = origin+        self.offset = offset+    }+}++public struct URLIdentityRule: Codable, Equatable, Sendable {+    public let version: Int+    public let component: URLRuleComponent+    public let origin: AnchorOrigin?+    public let offset: Int?+    public let queryName: String?++    public init(version: Int, component: URLRuleComponent, origin: AnchorOrigin, offset: Int) throws {+        guard version > 0 else { throw ModelInvariantError.nonPositiveVersion(field: "URL identity rule version") }+        guard component == .pathSegment else { throw ModelInvariantError.invalidCombination(field: "URL identity rule") }+        guard offset >= 0 else { throw ModelInvariantError.negativeOffset(field: "URL identity rule offset") }+        self.version = version+        self.component = component+        self.origin = origin+        self.offset = offset+        queryName = nil+    }++    public init(version: Int, component: URLRuleComponent, queryName: String) throws {+        guard version > 0 else { throw ModelInvariantError.nonPositiveVersion(field: "URL identity rule version") }+        guard component == .queryItem else { throw ModelInvariantError.invalidCombination(field: "URL identity rule") }+        guard !queryName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+            throw ModelInvariantError.blankValue(field: "URL identity query name")+        }+        self.version = version+        self.component = component+        origin = nil+        offset = nil+        self.queryName = queryName+    }+}++public struct JunkSuffixRule: Codable, Equatable, Sendable {+    public let version: Int+    public let anchors: [SegmentPositionSpec]++    public init(version: Int, anchors: [SegmentPositionSpec]) throws {+        guard version > 0 else { throw ModelInvariantError.nonPositiveVersion(field: "junk suffix rule version") }+        self.version = version+        self.anchors = anchors+    }+}++public struct FieldProvenance: Codable, Equatable, Sendable {+    public let kind: FieldProvenanceKind+    public let patternID: UUID?+    public let patternVersion: Int?++    public init(kind: FieldProvenanceKind, patternID: UUID? = nil, patternVersion: Int? = nil) throws {+        switch kind {+        case .pattern:+            guard patternID != nil, let patternVersion, patternVersion > 0 else {+                throw ModelInvariantError.invalidCombination(field: "field provenance")+            }+        case .none, .manual:+            guard patternID == nil, patternVersion == nil else {+                throw ModelInvariantError.invalidCombination(field: "field provenance")+            }+        }+        self.kind = kind+        self.patternID = patternID+        self.patternVersion = patternVersion+    }+}
Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift Added +63 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swiftnew file mode 100644index 0000000..2366778--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift@@ -0,0 +1,63 @@+import AsterismCore+import Foundation++@main+enum AsterismStoreTestHelper {+    static func main() async {+        do {+            let arguments = Array(CommandLine.arguments.dropFirst())+            guard let command = arguments.first else {+                throw HelperError.invalidArguments("missing command")+            }+            switch command {+            case "hold-lock":+                try await holdLock(arguments: Array(arguments.dropFirst()))+            case "open":+                try await openLibrary(arguments: Array(arguments.dropFirst()))+            default:+                throw HelperError.invalidArguments("unknown command")+            }+        } catch {+            FileHandle.standardError.write(Data("AsterismStoreTestHelper: \(error)\n".utf8))+            exit(1)+        }+    }++    private static func holdLock(arguments: [String]) async throws {+        guard arguments.count == 4,+              let mode = arguments[1] == "shared" ? LibraryLockMode.shared :+                (arguments[1] == "exclusive" ? LibraryLockMode.exclusive : nil),+              let milliseconds = Int(arguments[3]), milliseconds >= 0 else {+            throw HelperError.invalidArguments("hold-lock requires path, mode, ready path, and nonnegative milliseconds")+        }+        let lease = try await CrossProcessLibraryLock.acquire(+            mode: mode,+            at: URL(filePath: arguments[0]),+            timeout: .seconds(5)+        )+        try Data().write(to: URL(filePath: arguments[2]), options: .atomic)+        try await Task.sleep(for: .milliseconds(milliseconds))+        _ = lease+    }++    private static func openLibrary(arguments: [String]) async throws {+        guard arguments.count == 2,+              let environment = LibraryEnvironment(rawValue: arguments[1]) else {+            throw HelperError.invalidArguments("open requires root path and environment")+        }+        let configuration = LibraryConfiguration(+            rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory),+            environment: environment+        )+        let repository = try await LibraryRepository.open(configuration)+        _ = try await repository.debugCounts()+    }+}++private enum HelperError: Error, CustomStringConvertible {+    case invalidArguments(String)++    var description: String {+        switch self { case .invalidArguments(let reason): reason }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift Added +226 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swiftnew file mode 100644index 0000000..f616aa5--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift@@ -0,0 +1,226 @@+import Foundation+import Testing+@testable import AsterismCore++private func date(_ rfc3339: String) -> Date {+    let f = ISO8601DateFormatter()+    f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+    return f.date(from: rfc3339)!+}++// MARK: - BackupExporter Tests++@Suite("Backup snapshot and file lifecycle")+struct BackupExporterTests {+    // MARK: - Locked Point-in-Time Snapshot++    @Test("Backup snapshot reads a coherent point-in-time library state")+    func coherentSnapshot() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)++        // Create entries+        _ = try await repo.capture(CaptureDraft(captureTitle: "A", captureTitleSource: .manual, rawURLString: "https://a.test/1"))+        _ = try await repo.capture(CaptureDraft(captureTitle: "B", captureTitleSource: .manual, rawURLString: "https://a.test/2"))++        let snapshot = try await repo.backupSnapshot()+        #expect(snapshot.entries.count == 2)+        #expect(snapshot.sites.count == 1)+        #expect(snapshot.sites[0].hostname == "a.test")+    }++    @Test("Backup snapshot includes all entries, works, sites, and patterns")+    func fullInventory() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)++        let entry1 = try await repo.capture(CaptureDraft(captureTitle: "E1", captureTitleSource: .manual, rawURLString: "https://x.test/1"))+        _ = try await repo.createWork(NewWorkDraft(displayTitle: "W1", hostname: "x.test"))+        let entry2 = try await repo.capture(CaptureDraft(captureTitle: "E2", captureTitleSource: .host, rawURLString: "https://y.test/1"))++        let snapshot = try await repo.backupSnapshot()+        #expect(snapshot.entries.count == 2)+        #expect(snapshot.works.count == 1)+        #expect(snapshot.sites.count == 2)+        // All entry IDs present+        let entryIDs = Set(snapshot.entries.map(\.id))+        #expect(entryIDs.contains(entry1.id))+        #expect(entryIDs.contains(entry2.id))+    }++    // MARK: - BackupExporter Export++    @Test("Successful export produces a validated file at the staging path")+    func successfulExport() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)+        _ = try await repo.capture(CaptureDraft(captureTitle: "Test", captureTitleSource: .manual, rawURLString: "https://e.test/p"))++        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))++        #expect(FileManager.default.fileExists(atPath: result.fileURL.path))+        let data = try Data(contentsOf: result.fileURL)+        #expect(!data.isEmpty)+        // The file must be a valid backup+        let decoded = try BackupV1Codec.decode(data)+        #expect(decoded.snapshot.entries.count == 1)+    }++    @Test("Export file contains the exact validated bytes")+    func exactValidatedBytes() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)+        _ = try await repo.capture(CaptureDraft(captureTitle: "Verify", captureTitleSource: .manual, rawURLString: "https://v.test/x"))++        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        let result = try await exporter.export(metadata: BackupMetadata(appBuild: "2", databaseSchemaVersion: 1, exportedAt: Date()))++        let data = try Data(contentsOf: result.fileURL)+        let decoded = try BackupV1Codec.decode(data)+        // Validator must pass on the exact file bytes+        try BackupV1Validator.validate(decoded: decoded, source: decoded.snapshot, encodedData: data)+    }++    @Test("Empty library produces a valid zero-count backup")+    func emptyLibraryBackup() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)++        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))++        let data = try Data(contentsOf: result.fileURL)+        let decoded = try BackupV1Codec.decode(data)+        #expect(decoded.header.entryCount == 0)+        #expect(decoded.header.workCount == 0)+    }++    // MARK: - Failure Behavior++    @Test("No file is created when validation fails")+    func noFileOnFailure() 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 stagingDir = tempDir.appending(path: "BackupExports")++        let exporter = BackupExporter(+            repository: FailingBackupRepository(),+            stagingDirectory: stagingDir+        )+        do {+            _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+            Issue.record("Expected export to throw")+        } catch {+            // Verify no file was left behind+            if FileManager.default.fileExists(atPath: stagingDir.path) {+                let contents = try FileManager.default.contentsOfDirectory(atPath: stagingDir.path)+                #expect(contents.isEmpty, "No staged file should remain after failure")+            }+        }+    }++    // MARK: - Staging and Scavenging++    @Test("Cleanup removes the exported file")+    func cleanupRemovesFile() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)++        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+        #expect(FileManager.default.fileExists(atPath: result.fileURL.path))++        exporter.cleanup(result)+        #expect(!FileManager.default.fileExists(atPath: result.fileURL.path))+    }++    @Test("Scavenging removes files older than 24 hours")+    func scavengeOldFiles() async throws {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        let stagingDir = tempDir.appending(path: "BackupExports")+        try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }++        // Create a fake old file+        let oldFile = stagingDir.appending(path: "Asterism-backup-old.json")+        try Data("old".utf8).write(to: oldFile)+        // Set modification date to 25 hours ago+        let attrs: [FileAttributeKey: Any] = [.modificationDate: Date().addingTimeInterval(-25 * 3600)]+        try FileManager.default.setAttributes(attrs, ofItemAtPath: oldFile.path)++        // Create a recent file+        let recentFile = stagingDir.appending(path: "Asterism-backup-recent.json")+        try Data("recent".utf8).write(to: recentFile)++        let config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)+        let exporter = BackupExporter(repository: repo, stagingDirectory: stagingDir)+        exporter.scavengeStaleFiles()++        #expect(!FileManager.default.fileExists(atPath: oldFile.path), "Old file should be removed")+        #expect(FileManager.default.fileExists(atPath: recentFile.path), "Recent file should remain")+    }++    // MARK: - No Library Mutation++    @Test("Backup export does not mutate the library")+    func noLibraryMutation() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)+        _ = try await repo.capture(CaptureDraft(captureTitle: "X", captureTitleSource: .manual, rawURLString: "https://n.test/1"))++        let countsBefore = try await repo.debugCounts()+        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+        let countsAfter = try await repo.debugCounts()++        #expect(countsBefore == countsAfter, "Backup must not change library record counts")+    }++    @Test("Export file name contains UTC timestamp")+    func fileNameFormat() 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 config = LibraryConfiguration(rootDirectory: tempDir, environment: .development)+        let repo = try await LibraryRepository.open(config)++        let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))+        let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+        let filename = result.fileURL.lastPathComponent+        #expect(filename.hasPrefix("Asterism-backup-"))+        #expect(filename.hasSuffix(".json"))+    }+}++// MARK: - Test Double++/// A repository-like object that fails during backup snapshot for testing error paths.+private final class FailingBackupRepository: BackupSnapshotProviding, @unchecked Sendable {+    func backupSnapshot() async throws -> LibraryBackupSnapshot {+        throw LibraryRepositoryError.libraryUnavailable(operation: "backup snapshot", reason: "injected failure")+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift Added +226 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swiftnew file mode 100644index 0000000..8b7b2b1--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift@@ -0,0 +1,226 @@+import Foundation+import Testing++@testable import AsterismCore++// MARK: - Test Helpers++/// Loads a golden fixture file from the Fixtures/BackupV1 directory.+private func loadFixture(_ name: String) throws -> Data {+    let url = URL(fileURLWithPath: #filePath)+        .deletingLastPathComponent()+        .appending(path: "Fixtures/BackupV1/\(name)")+    return try Data(contentsOf: url)+}++/// Reference date helper: parses RFC 3339 with milliseconds.+private func date(_ rfc3339: String) -> Date {+    let formatter = ISO8601DateFormatter()+    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+    return formatter.date(from: rfc3339)!+}++/// Well-known UUIDs used in golden fixtures.+private enum FixtureIDs {+    static let entry1 = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!+    static let entry2 = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!+    static let work1 = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!+    static let pattern1 = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!+    static let pattern2 = UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!+}++// MARK: - Expected Snapshots for Golden Fixtures++private let expectedEmptySnapshot = LibraryBackupSnapshot.empty++private let expectedNonEmptySnapshot: LibraryBackupSnapshot = {+    let entry1 = EntryRecord(+        id: FixtureIDs.entry1,+        captureTitle: "Chapter 1 — 日本語テスト",+        captureTitleSource: .safariDocument,+        rawURL: "https://example.com/manga/123/1?utm_source=twitter",+        canonicalURL: "https://example.com/chapter-1",+        hostname: "example.com",+        entryIdentityKey: "https://example.com/manga/123/1",+        identityKeyVersion: 1,+        chapterTitle: "Chapter 1",+        chapterTitleProvenance: try! FieldProvenance(+            kind: .pattern, patternID: FixtureIDs.pattern1, patternVersion: 2+        ),+        note: "Great chapter with émojis 🎉 and\nnewlines",+        rating: .up,+        firstCapturedAt: date("2026-07-01T10:00:00.000Z"),+        lastSharedAt: date("2026-07-01T10:00:00.000Z"),+        modifiedAt: date("2026-07-02T15:30:00.500Z"),+        workID: FixtureIDs.work1,+        workAssignmentProvenance: try! FieldProvenance(kind: .manual),+        intentionallyUnattached: false+    )+    let entry2 = EntryRecord(+        id: FixtureIDs.entry2,+        captureTitle: "Untitled Page",+        captureTitleSource: .manual,+        rawURL: "https://other.test/article",+        canonicalURL: nil,+        hostname: "other.test",+        entryIdentityKey: "https://other.test/article",+        identityKeyVersion: 1,+        chapterTitle: nil,+        chapterTitleProvenance: try! FieldProvenance(kind: .none),+        note: "",+        rating: nil,+        firstCapturedAt: date("2026-07-03T08:15:00.123Z"),+        lastSharedAt: date("2026-07-03T08:15:00.123Z"),+        modifiedAt: date("2026-07-03T08:15:00.123Z"),+        workID: nil,+        workAssignmentProvenance: try! FieldProvenance(kind: .manual),+        intentionallyUnattached: true+    )+    let work1 = WorkRecord(+        id: FixtureIDs.work1,+        displayTitle: "My Manga Series",+        lastParsedTitle: "My Manga",+        siteHostname: "example.com",+        urlIdentity: nil,+        workURL: "https://example.com/manga/123",+        genericNotes: "Notes about the series",+        type: .toon,+        genreTags: ["fantasy", "action"],+        titleProvenance: .manual,+        createdAt: date("2026-07-01T09:00:00.000Z"),+        modifiedAt: date("2026-07-02T15:30:00.500Z"),+        entryIDs: [FixtureIDs.entry1]+    )+    let site1 = SiteRecord(+        hostname: "example.com",+        displayName: "example.com",+        mode: .taught,+        patternIDs: [FixtureIDs.pattern1, FixtureIDs.pattern2],+        urlIdentityRule: try! URLIdentityRule(version: 1, component: .pathSegment, origin: .start, offset: 1),+        junkSuffixRule: try! JunkSuffixRule(version: 1, anchors: [+            try! SegmentPositionSpec(origin: .end, offset: 1),+        ])+    )+    let site2 = SiteRecord(+        hostname: "other.test",+        displayName: "other.test",+        mode: .untaught,+        patternIDs: [],+        urlIdentityRule: nil,+        junkSuffixRule: nil+    )+    let pattern1 = TitlePatternRecord(+        id: FixtureIDs.pattern1,+        version: 2,+        isActive: true,+        createdAt: date("2026-06-15T12:00:00.000Z"),+        workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 2),+        junkAnchors: [+            try! SegmentPositionSpec(origin: .end, offset: 0),+            try! SegmentPositionSpec(origin: .start, offset: 2),+        ],+        siteHostname: "example.com"+    )+    let pattern2 = TitlePatternRecord(+        id: FixtureIDs.pattern2,+        version: 1,+        isActive: false,+        createdAt: date("2026-06-10T08:00:00.000Z"),+        workAnchor: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),+        junkAnchors: [],+        siteHostname: "example.com"+    )+    return LibraryBackupSnapshot(+        entries: [entry1, entry2],+        works: [work1],+        sites: [site1, site2],+        titlePatterns: [pattern1, pattern2]+    )+}()++// MARK: - Golden Fixture Decode Tests++@Suite("BackupV1Codec Golden Fixtures")+struct BackupV1GoldenFixtureTests {+    @Test("Decodes empty golden archive to empty snapshot")+    func decodeEmptyGolden() throws {+        let data = try loadFixture("empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        #expect(decoded.snapshot == expectedEmptySnapshot)+        #expect(decoded.header.backupFormatVersion == 1)+        #expect(decoded.header.databaseSchemaVersion == 1)+        #expect(decoded.header.appBuild == "1")+        #expect(decoded.header.entryCount == 0)+        #expect(decoded.header.workCount == 0)+        #expect(decoded.header.exportedAt == date("2026-07-17T00:00:00.000Z"))+    }++    @Test("Decodes non-empty golden archive to expected snapshot")+    func decodeNonEmptyGolden() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        #expect(decoded.snapshot == expectedNonEmptySnapshot)+        #expect(decoded.header.backupFormatVersion == 1)+        #expect(decoded.header.databaseSchemaVersion == 1)+        #expect(decoded.header.appBuild == "42")+        #expect(decoded.header.entryCount == 2)+        #expect(decoded.header.workCount == 1)+    }++    @Test("Non-empty golden archive contains both provenances")+    func goldenProvenances() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!+        #expect(entry1.chapterTitleProvenance.kind == .pattern)+        #expect(entry1.chapterTitleProvenance.patternID == FixtureIDs.pattern1)+        #expect(entry1.chapterTitleProvenance.patternVersion == 2)+        #expect(entry1.workAssignmentProvenance.kind == .manual)+        #expect(entry1.workAssignmentProvenance.patternID == nil)+    }++    @Test("Non-empty golden archive preserves Unicode and special characters")+    func goldenUnicode() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!+        #expect(entry1.captureTitle == "Chapter 1 — 日本語テスト")+        #expect(entry1.note.contains("émojis"))+        #expect(entry1.note.contains("🎉"))+        #expect(entry1.note.contains("\n"))+    }++    @Test("Non-empty golden archive preserves millisecond dates")+    func goldenMillisecondDates() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!+        #expect(entry1.modifiedAt == date("2026-07-02T15:30:00.500Z"))+        let entry2 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry2 }!+        #expect(entry2.firstCapturedAt == date("2026-07-03T08:15:00.123Z"))+    }++    @Test("Non-empty golden archive preserves null optionals explicitly")+    func goldenNulls() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let entry2 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry2 }!+        #expect(entry2.canonicalURL == nil)+        #expect(entry2.chapterTitle == nil)+        #expect(entry2.rating == nil)+        #expect(entry2.workID == nil)+    }++    @Test("Non-empty golden archive has inverse-ID arrays sorted by lowercase UUID")+    func goldenInverseIDOrdering() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let work = decoded.snapshot.works.first { $0.id == FixtureIDs.work1 }!+        // entryIDs must be sorted by lowercase UUID string+        let sortedIDs = work.entryIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        #expect(work.entryIDs == sortedIDs)+        let site = decoded.snapshot.sites.first { $0.hostname == "example.com" }!+        let sortedPatterns = site.patternIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        #expect(site.patternIDs == sortedPatterns)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swift Added +233 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swiftnew file mode 100644index 0000000..1a4ac9c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swift@@ -0,0 +1,233 @@+import Foundation+import Testing+@testable import AsterismCore++private func date(_ rfc3339: String) -> Date {+    let f = ISO8601DateFormatter()+    f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+    return f.date(from: rfc3339)!+}++private func loadFixture(_ name: String) throws -> Data {+    let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent()+        .appending(path: "Fixtures/BackupV1/\(name)")+    return try Data(contentsOf: url)+}++// MARK: - Encoding Tests++@Suite("BackupV1Codec Encoding")+struct BackupV1EncodingTests {+    private let metadata = BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: date("2026-07-17T00:00:00.000Z"))++    @Test("Encodes empty snapshot to valid canonical JSON") func encodeEmpty() throws {+        let data = try BackupV1Codec.encode(snapshot: .empty, metadata: metadata)+        #expect(!data.isEmpty)+        #expect(String(data: data, encoding: .utf8) != nil)+    }++    @Test("Empty encode matches golden fixture bytes exactly") func emptyMatchesGolden() throws {+        let golden = try loadFixture("empty-library.json")+        let encoded = try BackupV1Codec.encode(snapshot: .empty, metadata: metadata)+        #expect(encoded == golden, "Canonical re-encode must produce byte-identical output")+    }++    @Test("Canonical re-encode of decoded non-empty golden is byte-identical") func reEncodeNonEmpty() throws {+        let golden = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(golden)+        let reMeta = BackupMetadata(appBuild: decoded.header.appBuild, databaseSchemaVersion: decoded.header.databaseSchemaVersion, exportedAt: decoded.header.exportedAt)+        let reEncoded = try BackupV1Codec.encode(snapshot: decoded.snapshot, metadata: reMeta)+        #expect(reEncoded == golden, "Canonical re-encode must produce byte-identical output")+    }++    @Test("No BOM or trailing whitespace") func noBOMOrWhitespace() throws {+        let bytes = [UInt8](try BackupV1Codec.encode(snapshot: .empty, metadata: metadata))+        if bytes.count >= 3 { #expect(!(bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)) }+        #expect(bytes.first == UInt8(ascii: "{")); #expect(bytes.last == UInt8(ascii: "}"))+    }++    @Test("No insignificant whitespace") func noWhitespace() throws {+        let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!+        #expect(!s.contains(": ")); #expect(!s.contains(", "))+    }++    @Test("Envelope has header before payload") func envelopeOrder() throws {+        let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!+        #expect(s.hasPrefix("{\"header\":"))+        let h = s.range(of: "\"header\":")!.lowerBound+        let p = s.range(of: "\"payload\":")!.lowerBound+        #expect(h < p)+    }++    @Test("Dates use RFC 3339 UTC with exactly three fractional digits") func dateFormat() throws {+        let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!+        let pattern = #/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/#+        #expect(s.firstMatch(of: pattern) != nil)+    }++    @Test("UUIDs are lowercase") func uuidLowercase() throws {+        let snapshot = LibraryBackupSnapshot(entries: [], works: [WorkRecord(id: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, displayTitle: "T", lastParsedTitle: nil, siteHostname: "t.com", urlIdentity: nil, workURL: nil, genericNotes: "", type: .other, genreTags: [], titleProvenance: .manual, createdAt: metadata.exportedAt, modifiedAt: metadata.exportedAt, entryIDs: [])], sites: [SiteRecord(hostname: "t.com", displayName: "t.com", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        let s = String(data: try BackupV1Codec.encode(snapshot: snapshot, metadata: metadata), encoding: .utf8)!+        #expect(s.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))+        #expect(!s.contains("AAAAAAAA"))+    }+}++// MARK: - Strict Decoding Tests++@Suite("BackupV1Codec Strict Decoding")+struct BackupV1StrictDecodingTests {+    @Test("Rejects unknown top-level keys") func unknownTopLevel() {+        let j = #"{"extra":true,"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }+    }+    @Test("Rejects wrong format version") func wrongFormat() {+        let j = #"{"header":{"appBuild":"1","backupFormatVersion":2,"checksum":"a","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }+    }+    @Test("Rejects wrong schema version") func wrongSchema() {+        let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":2,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }+    }+    @Test("Rejects trailing bytes") func trailing() throws {+        var data = try loadFixture("empty-library.json"); data.append(contentsOf: " ".utf8)+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(data) }+    }+    @Test("Rejects invalid enum values") func invalidEnum() {+        let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":1,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[{"canonicalURL":null,"captureTitle":"T","captureTitleSource":"INVALID","chapterTitle":null,"chapterTitleProvenance":{"kind":"none","patternID":null,"patternVersion":null},"entryIdentityKey":"k","firstCapturedAt":"2026-07-17T00:00:00.000Z","hostname":"h","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-17T00:00:00.000Z","modifiedAt":"2026-07-17T00:00:00.000Z","note":"","rating":null,"rawURL":"https://h/p","workAssignmentProvenance":{"kind":"none","patternID":null,"patternVersion":null},"workID":null}],"sites":[],"titlePatterns":[],"works":[]}}"#+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }+    }+    @Test("Rejects invalid provenance: pattern kind without patternID") func badProvenance() {+        let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":1,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[{"canonicalURL":null,"captureTitle":"T","captureTitleSource":"manual","chapterTitle":null,"chapterTitleProvenance":{"kind":"pattern","patternID":null,"patternVersion":null},"entryIdentityKey":"k","firstCapturedAt":"2026-07-17T00:00:00.000Z","hostname":"h","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-17T00:00:00.000Z","modifiedAt":"2026-07-17T00:00:00.000Z","note":"","rating":null,"rawURL":"https://h/p","workAssignmentProvenance":{"kind":"none","patternID":null,"patternVersion":null},"workID":null}],"sites":[],"titlePatterns":[],"works":[]}}"#+        #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }+    }+}++// MARK: - SHA-256 Tampering Tests++@Suite("BackupV1Codec Tampering")+struct BackupV1TamperingTests {+    @Test("Detects single-byte payload modification") func singleByte() throws {+        var data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        data[decoded.payloadRange.lowerBound + decoded.payloadRange.count / 2] ^= 0x01+        #expect(throws: (any Error).self) {+            let t = try BackupV1Codec.decode(data)+            try BackupV1Validator.validate(decoded: t, source: decoded.snapshot, encodedData: data)+        }+    }+    @Test("Detects checksum replacement") func checksumReplace() throws {+        let data = try loadFixture("empty-library.json")+        var s = String(data: data, encoding: .utf8)!+        s = s.replacingOccurrences(of: "9e81a28e8b352eccadedd5d6d77c7dace28189cfb1b5951fa41397415aa4eebd", with: String(repeating: "0", count: 64))+        let decoded = try BackupV1Codec.decode(Data(s.utf8))+        #expect(throws: (any Error).self) {+            try BackupV1Validator.validate(decoded: decoded, source: .empty, encodedData: Data(s.utf8))+        }+    }+}++// MARK: - Deep Equality & Validation Tests++@Suite("BackupV1Validator")+struct BackupV1ValidatorTests {+    @Test("Validates non-empty golden against its own snapshot") func validNonEmpty() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        try BackupV1Validator.validate(decoded: decoded, source: decoded.snapshot, encodedData: data)+    }+    @Test("Validates empty golden against empty snapshot") func validEmpty() throws {+        let data = try loadFixture("empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        try BackupV1Validator.validate(decoded: decoded, source: .empty, encodedData: data)+    }+    @Test("Detects missing source entry") func missingEntry() throws {+        let data = try loadFixture("non-empty-library.json")+        let decoded = try BackupV1Codec.decode(data)+        let partial = LibraryBackupSnapshot(entries: Array(decoded.snapshot.entries.dropLast()), works: decoded.snapshot.works, sites: decoded.snapshot.sites, titlePatterns: decoded.snapshot.titlePatterns)+        #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: decoded, source: partial, encodedData: data) }+    }+    @Test("Rejects duplicate Entry UUIDs") func dupEntries() throws {+        let ts = date("2026-07-17T00:00:00.000Z")+        let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://a.t/p", canonicalURL: nil, hostname: "a.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e, e], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))+        let dec = try BackupV1Codec.decode(enc)+        #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }+    }+    @Test("Rejects unresolved Work reference") func unresolvedWork() throws {+        let ts = date("2026-07-17T00:00:00.000Z")+        let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://a.t/p", canonicalURL: nil, hostname: "a.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: UUID(), workAssignmentProvenance: try! FieldProvenance(kind: .manual), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))+        let dec = try BackupV1Codec.decode(enc)+        #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }+    }+    @Test("Rejects unresolved hostname") func unresolvedHostname() throws {+        let ts = date("2026-07-17T00:00:00.000Z")+        let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://m.t/p", canonicalURL: nil, hostname: "m.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [], titlePatterns: [])+        let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))+        let dec = try BackupV1Codec.decode(enc)+        #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }+    }+    @Test("Rejects multiple active patterns per Site") func multiActive() throws {+        let ts = date("2026-07-17T00:00:00.000Z")+        let p1 = TitlePatternRecord(id: UUID(), version: 1, isActive: true, createdAt: ts, workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1), junkAnchors: [], siteHostname: "a.t")+        let p2 = TitlePatternRecord(id: UUID(), version: 2, isActive: true, createdAt: ts, workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1), junkAnchors: [], siteHostname: "a.t")+        let snap = LibraryBackupSnapshot(entries: [], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .taught, patternIDs: [p1.id, p2.id], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [p1, p2])+        let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))+        let dec = try BackupV1Codec.decode(enc)+        #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }+    }+}++// MARK: - Generative Property Tests++@Suite("BackupV1Codec Generative")+struct BackupV1GenerativeTests {+    private struct Gen { var s: UInt64; mutating func next() -> UInt64 { s ^= s &<< 13; s ^= s &>> 7; s ^= s &<< 17; return s }+        mutating func uuid() -> UUID { let a = next(); let b = next(); return UUID(uuid: withUnsafeBytes(of: (a,b)) { p in (p[0],p[1],p[2],p[3],p[4],p[5],p[6],p[7],p[8],p[9],p[10],p[11],p[12],p[13],p[14],p[15]) }) }+        mutating func date() -> Date { Date(timeIntervalSince1970: 1_735_689_600 + Double(next() % 63_072_000_000) / 1000) }+        mutating func str() -> String { String(next() % 10000) }+    }++    @Test("Round-trip preserves deep equality", arguments: [1, 42, 99, 256, 1000] as [UInt64])+    func roundTrip(seed: UInt64) throws {+        var g = Gen(s: seed)+        let h = "g\(seed).t"+        let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .manual, rawURL: "https://\(h)/p", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: g.str(), rating: nil, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date()))+        let dec = try BackupV1Codec.decode(enc)+        #expect(dec.snapshot == snap)+    }++    @Test("Canonical re-encode is byte-identical", arguments: [7, 13, 77] as [UInt64])+    func reEncode(seed: UInt64) throws {+        var g = Gen(s: seed)+        let h = "r\(seed).t"+        let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .host, rawURL: "https://\(h)/x", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/y", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: .up, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        let meta = BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date())+        let first = try BackupV1Codec.encode(snapshot: snap, metadata: meta)+        let dec = try BackupV1Codec.decode(first)+        let second = try BackupV1Codec.encode(snapshot: dec.snapshot, metadata: BackupMetadata(appBuild: dec.header.appBuild, databaseSchemaVersion: dec.header.databaseSchemaVersion, exportedAt: dec.header.exportedAt))+        #expect(first == second)+    }++    @Test("Byte flip changes SHA-256 or fails decode", arguments: [3, 50, 200] as [UInt64])+    func byteFlip(seed: UInt64) throws {+        var g = Gen(s: seed)+        let h = "f\(seed).t"+        let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .manual, rawURL: "https://\(h)/z", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/z", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)+        let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])+        var enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date()))+        let dec = try BackupV1Codec.decode(enc)+        let idx = dec.payloadRange.lowerBound + Int(g.next() % UInt64(dec.payloadRange.count))+        enc[idx] ^= 0x01+        do { let t = try BackupV1Codec.decode(enc)+            #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: t, source: snap, encodedData: enc) }+        } catch { /* decode failure is also acceptable */ }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift Added +112 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swiftnew file mode 100644index 0000000..96abe75--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift@@ -0,0 +1,112 @@+import Foundation+import Testing+@testable import AsterismCore++// MARK: - CaptureCoordinator canonical resolution against final response URL++@Suite("Fetched canonical resolves against final response URL", .serialized)+struct FetchedCanonicalResolutionTests {++    @Test("CaptureCoordinator resolves fetched relative canonical against finalURL")+    func coordinatorResolvesAgainstFinalURL() async throws {+        let fetcher = FinalURLRecordingFetcher(+            title: "Page Title",+            canonicalCandidates: ["/resolved-canonical"],+            finalURL: "https://redirected.example.com/base/"+        )++        let directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismCanonicalResolution-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: directory) }++        let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+        let repository = try await LibraryRepository.open(+            config,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))+        )+        let coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)++        let payload = SharePayload(providerURL: "https://example.com/original-url")+        let result = try await coordinator.prepare(payload)++        // Canonical resolved against finalURL, not provider URL+        #expect(result.canonicalURL == "https://redirected.example.com/resolved-canonical")+        #expect(result.rawURL == "https://example.com/original-url")+    }++    @Test("Fetcher without redirect: canonical resolves against raw URL")+    func noRedirectFallsBackToRawURL() async throws {+        let fetcher = FinalURLRecordingFetcher(+            title: "No Redirect",+            canonicalCandidates: ["/local-canonical"],+            finalURL: nil+        )++        let directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismCanonicalNoRedirect-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: directory) }++        let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+        let repository = try await LibraryRepository.open(+            config,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))+        )+        let coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)++        let payload = SharePayload(providerURL: "https://example.com/page")+        let result = try await coordinator.prepare(payload)++        #expect(result.canonicalURL == "https://example.com/local-canonical")+    }++    @Test("Absolute canonical with finalURL still resolves correctly")+    func absoluteCanonicalWithFinalURL() async throws {+        let fetcher = FinalURLRecordingFetcher(+            title: "Abs",+            canonicalCandidates: ["https://cdn.example.com/canonical"],+            finalURL: "https://redirected.example.com/page"+        )++        let directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismCanonicalAbs-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: directory) }++        let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+        let repository = try await LibraryRepository.open(+            config,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))+        )+        let coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)++        let payload = SharePayload(providerURL: "https://example.com/original")+        let result = try await coordinator.prepare(payload)++        // Absolute canonical is preserved regardless of base+        #expect(result.canonicalURL == "https://cdn.example.com/canonical")+    }+}++// MARK: - Test helper++private final class FinalURLRecordingFetcher: PageTitleFetching, @unchecked Sendable {+    let title: String?+    let canonicalCandidates: [String]+    let finalURL: String?++    init(title: String?, canonicalCandidates: [String], finalURL: String?) {+        self.title = title+        self.canonicalCandidates = canonicalCandidates+        self.finalURL = finalURL+    }++    func fetch(url: String) async throws -> FetchedPageMetadata? {+        FetchedPageMetadata(+            title: title,+            canonicalCandidates: canonicalCandidates,+            finalURL: finalURL+        )+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetcherURLProtocolTests.swift Added +320 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetcherURLProtocolTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetcherURLProtocolTests.swiftnew file mode 100644index 0000000..a1bdaa7--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetcherURLProtocolTests.swift@@ -0,0 +1,320 @@+import Foundation+import Testing+@testable import AsterismCore++// MARK: - URLProtocol stub for deterministic network tests++final class StubURLProtocol: URLProtocol, @unchecked Sendable {+    nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?+    nonisolated(unsafe) static var delaySeconds: Double = 0+    private static let stateLock = NSLock()+    nonisolated(unsafe) private static var _stopLoadingCount = 0++    private let instanceLock = NSLock()+    private var stopped = false++    static var stopLoadingCount: Int { stateLock.withLock { _stopLoadingCount } }+    static func resetStopLoadingCount() { stateLock.withLock { _stopLoadingCount = 0 } }++    override class func canInit(with request: URLRequest) -> Bool { true }+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }++    override func startLoading() {+        guard let handler = Self.requestHandler else {+            client?.urlProtocol(self, didFailWithError: URLError(.unknown))+            return+        }+        let delay = Self.delaySeconds+        DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in+            guard let self, !self.instanceLock.withLock({ self.stopped }) else { return }+            do {+                let (response, data) = try handler(self.request)+                guard !self.instanceLock.withLock({ self.stopped }) else { return }+                self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)+                self.client?.urlProtocol(self, didLoad: data)+                self.client?.urlProtocolDidFinishLoading(self)+            } catch {+                self.client?.urlProtocol(self, didFailWithError: error)+            }+        }+    }++    override func stopLoading() {+        instanceLock.withLock { stopped = true }+        Self.stateLock.withLock { Self._stopLoadingCount += 1 }+    }+}++// MARK: - Helper to build HTTP responses++private func httpResponse(url: String, status: Int, mime: String? = "text/html", charset: String? = nil) -> HTTPURLResponse {+    var headers: [String: String] = [:]+    if let mime = mime {+        var ct = mime+        if let charset = charset { ct += "; charset=\(charset)" }+        headers["Content-Type"] = ct+    }+    return HTTPURLResponse(+        url: URL(string: url)!,+        statusCode: status,+        httpVersion: "HTTP/1.1",+        headerFields: headers+    )!+}++// MARK: - Task 14 URLProtocol-driven fetcher tests++@Suite("BoundedPageTitleFetcher URLProtocol tests", .serialized)+struct BoundedFetcherURLProtocolTests {++    @Test("Successful fetch extracts title from HTML response")+    func successfulTitleExtraction() async throws {+        let html = "<html><head><title>Hello World</title></head><body></body></html>"+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/page", status: 200), Data(html.utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/page")+        #expect(result?.title == "Hello World")+    }++    @Test("Non-2xx status returns nil (no title)")+    func non2xxStatusReturnsNil() async throws {+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/page", status: 404), Data())+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/page")+        #expect(result == nil)+    }++    @Test("Unacceptable MIME type returns nil")+    func unacceptableMIMEReturnsNil() async throws {+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/data.json", status: 200, mime: "application/json"), Data("{}".utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/data.json")+        #expect(result == nil)+    }++    @Test("Absent MIME type is accepted")+    func absentMIMEAccepted() async throws {+        let html = "<html><head><title>No MIME</title></head></html>"+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/page", status: 200, mime: nil), Data(html.utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/page")+        #expect(result?.title == "No MIME")+    }++    @Test("Exact 65,536-byte boundary: title within limit extracted")+    func exactByteBoundaryWithinLimit() async throws {+        // Title appears well within the 65,536 byte limit+        let title = "Within Limit Title"+        let html = "<html><head><title>\(title)</title></head><body>" + String(repeating: "x", count: 66_000) + "</body></html>"+        let data = Data(html.utf8)+        #expect(data.count > 65_536) // Total body exceeds limit++        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/big", status: 200), data)+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/big")+        // Title is in the head, well before 65,536 bytes+        #expect(result?.title == title)+    }++    @Test("Title beyond 65,536 byte boundary is not extracted")+    func titleBeyondByteBoundary() async throws {+        // Push title past the boundary+        let padding = String(repeating: " ", count: 65_530)+        let html = "<html><head>\(padding)<title>Late</title></head></html>"+        let data = Data(html.utf8)++        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/late", status: 200), data)+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/late")+        #expect(result?.title == nil)+    }++    @Test("HTTP charset parameter used for decoding")+    func httpCharsetUsed() async throws {+        // Latin-1 encoded content with charset in Content-Type+        let htmlString = "<html><head><title>Café</title></head></html>"+        let data = htmlString.data(using: .isoLatin1)!++        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/cafe", status: 200, mime: "text/html", charset: "iso-8859-1"), data)+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/cafe")+        #expect(result?.title == "Café")+    }++    @Test("Canonical candidates extracted from fetched HTML")+    func canonicalCandidatesExtracted() async throws {+        let html = """+        <html><head>+        <link rel="canonical" href="https://example.com/canonical">+        <title>Page</title>+        </head></html>+        """+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/page", status: 200), Data(html.utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/page")+        #expect(result?.canonicalCandidates == ["https://example.com/canonical"])+    }++    @Test("Scanner respects 65,536-byte boundary for chunk-split head")+    func scannerRespectsByteBoundary() async throws {+        // Create HTML where canonical is within limit but body extends beyond+        let html = "<html><head><link rel=\"canonical\" href=\"https://example.com/c\"><title>T</title></head><body>" + String(repeating: "y", count: 70_000) + "</body></html>"+        let data = Data(html.utf8)++        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/chunk", status: 200), data)+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/chunk")+        #expect(result?.title == "T")+        #expect(result?.canonicalCandidates == ["https://example.com/c"])+    }++    @Test("Invalid URL throws PageTitleFetchError")+    func invalidURLThrows() async throws {+        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        await #expect(throws: PageTitleFetchError.self) {+            _ = try await fetcher.fetch(url: "")+        }+    }++    @Test("Malformed HTML with no closing head still extracts title")+    func malformedHTMLNoClosingHead() async throws {+        let html = "<html><head><title>Broken Page</title><body>content"+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/broken", status: 200), Data(html.utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/broken")+        #expect(result?.title == "Broken Page")+    }++    // MARK: - Byte-limit enforcement behavior tests++    @Test("Exact 65,536-byte response succeeds (no truncation)")+    func exactLimitSucceeds() async throws {+        let titleHTML = "<html><head><title>Exact Limit</title></head><body>"+        let suffix = "</body></html>"+        let paddingCount = 65_536 - titleHTML.utf8.count - suffix.utf8.count+        let padding = String(repeating: "x", count: paddingCount)+        let fullHTML = titleHTML + padding + suffix+        let data = Data(fullHTML.utf8)+        #expect(data.count == 65_536, "Test data must be exactly 65,536 bytes, got \(data.count)")++        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/exact", status: 200), data)+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/exact")+        #expect(result?.title == "Exact Limit")+    }++    @Test("One monotonic 3-second deadline enforced as constants")+    func totalDeadlineEnforced() async throws {+        let fetcher = BoundedPageTitleFetcher()+        #expect(fetcher.deadline == .seconds(3))+        #expect(fetcher.maxBodyBytes == 65_536)+    }++    @Test("FetchedPageMetadata carries finalURL (request URL when no redirect)")+    func metadataHasFinalURL() async throws {+        let html = "<html><head><title>Has URL</title><link rel=\"canonical\" href=\"/canon\"></head></html>"+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/page", status: 200), Data(html.utf8))+        }+        StubURLProtocol.delaySeconds = 0++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let result = try await fetcher.fetch(url: "https://example.com/page")+        #expect(result?.title == "Has URL")+        // finalURL is tracked by delegate (without redirect, it's the request URL)+        #expect(result?.finalURL != nil)+        #expect(result?.canonicalCandidates == ["/canon"])+    }++    @Test("Parent task cancellation cancels URLSession even after response work starts")+    func parentCancellationCancelsNetworkWork() async throws {+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/slow", status: 200), Data("<title>Too late</title>".utf8))+        }+        StubURLProtocol.delaySeconds = 1+        StubURLProtocol.resetStopLoadingCount()++        let fetcher = BoundedPageTitleFetcher(protocolClasses: [StubURLProtocol.self])+        let task = Task { try await fetcher.fetch(url: "https://example.com/slow") }+        try await Task.sleep(for: .milliseconds(20))+        task.cancel()++        await #expect(throws: CancellationError.self) {+            _ = try await task.value+        }+        for _ in 0..<100 {+            if StubURLProtocol.stopLoadingCount > 0 { break }+            try await Task.sleep(for: .milliseconds(1))+        }+        #expect(StubURLProtocol.stopLoadingCount > 0)+    }++    @Test("One monotonic deadline cancels the complete request")+    func monotonicDeadlineCancelsRequest() async throws {+        StubURLProtocol.requestHandler = { _ in+            (httpResponse(url: "https://example.com/deadline", status: 200), Data("<title>Too late</title>".utf8))+        }+        StubURLProtocol.delaySeconds = 1+        StubURLProtocol.resetStopLoadingCount()++        let fetcher = BoundedPageTitleFetcher(+            protocolClasses: [StubURLProtocol.self],+            deadline: .milliseconds(30)+        )+        let clock = ContinuousClock()+        let started = clock.now+        await #expect(throws: PageTitleFetchError.self) {+            _ = try await fetcher.fetch(url: "https://example.com/deadline")+        }+        #expect(started.duration(to: clock.now) < .seconds(1))+        for _ in 0..<100 {+            if StubURLProtocol.stopLoadingCount > 0 { break }+            try await Task.sleep(for: .milliseconds(1))+        }+        #expect(StubURLProtocol.stopLoadingCount > 0)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift Added +314 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftnew file mode 100644index 0000000..c242502--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -0,0 +1,314 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++// MARK: - Task 16: Capture state and extension lifecycle tests++@Suite("Capture state and extension lifecycle", .serialized)+struct CaptureStateTests {++    // MARK: - State machine: loading → ready/invalidInput++    @Test("State begins as loading before preparation completes")+    func initialStateIsLoading() async throws {+        let viewModel = await CaptureViewModel()+        let isLoading = await viewModel.state.isLoading+        #expect(isLoading)+    }++    @Test("Valid Safari payload transitions to ready with new-site status")+    func validPayloadTransitionsToReady() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "Chapter 5", locationHref: "https://new-site.example/ch5")+        let payload = SharePayload(providerURL: "https://new-site.example/ch5", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .ready(let preparation, _) = state else {+            Issue.record("Expected ready state")+            return+        }+        #expect(preparation.siteStatus == .newSite)+        #expect(preparation.resolvedTitle == "Chapter 5")+    }++    @Test("Existing untaught site produces ready state without new-site wording")+    func existingUntaughtSiteReady() async throws {+        let fixture = try await CaptureStateFixture()+        // Seed a Site+        _ = try await fixture.repository.capture(CaptureDraft(+            captureTitle: "Seed",+            captureTitleSource: .manual,+            rawURLString: "https://existing.example/first"+        ))+        let safari = SafariPageMetadata(title: "Chapter 2", locationHref: "https://existing.example/ch2")+        let payload = SharePayload(providerURL: "https://existing.example/ch2", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .ready(let preparation, _) = state else {+            Issue.record("Expected ready state")+            return+        }+        #expect(preparation.siteStatus == .existingUntaught)+    }++    @Test("Manual title state when Safari title is blank")+    func manualTitleState() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .ready(let preparation, _) = state else {+            Issue.record("Expected ready state")+            return+        }+        #expect(preparation.titleSource == .manual)+        #expect(preparation.resolvedTitle == nil)+    }++    // MARK: - Ratings++    @Test("Rating toggles: tap selected rating clears it")+    func ratingToggleClear() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.setRating(.up)+        let draft1 = await fixture.viewModel.currentDraft+        #expect(draft1?.rating == .up)+        await fixture.viewModel.setRating(.up) // tap same → clear+        let draft2 = await fixture.viewModel.currentDraft+        #expect(draft2?.rating == nil)+    }++    @Test("Rating toggles: tap opposite replaces current")+    func ratingToggleReplace() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.setRating(.up)+        await fixture.viewModel.setRating(.down) // tap opposite → replace+        let draft = await fixture.viewModel.currentDraft+        #expect(draft?.rating == .down)+    }++    // MARK: - Empty note/rating++    @Test("Save succeeds with empty note and nil rating")+    func saveWithEmptyNoteAndRating() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.setNote("")+        await fixture.viewModel.setRating(nil)+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .saved = state else {+            Issue.record("Expected saved state, got \(state)")+            return+        }+    }++    // MARK: - Duplicate-save suppression++    @Test("Second save call ignored after successful save")+    func secondSaveIgnored() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .saved(let firstID) = state else {+            Issue.record("Expected saved state")+            return+        }+        // Second save should be a no-op+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state2 = await fixture.viewModel.state+        guard case .saved(let secondID) = state2 else {+            Issue.record("Expected saved state")+            return+        }+        #expect(firstID == secondID)+    }++    // MARK: - Draft retention after failure++    @Test("Failed save retains draft data and allows retry")+    func failedSaveRetainsDraft() async throws {+        let saveStrategy = FailingSaveStrategy()+        let fixture = try await CaptureStateFixture(saveStrategy: saveStrategy)+        await fixture.loadDefault()+        await fixture.viewModel.setNote("Important note")+        await fixture.viewModel.setRating(.up)+        saveStrategy.failSaves = true+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .saveFailed(_, let draft, _) = state else {+            Issue.record("Expected saveFailed state, got \(state)")+            return+        }+        #expect(draft.note == "Important note")+        #expect(draft.rating == .up)+    }++    @Test("Retry after failure succeeds when persistence is restored")+    func retryAfterFailure() async throws {+        let saveStrategy = FailingSaveStrategy()+        let fixture = try await CaptureStateFixture(saveStrategy: saveStrategy)+        await fixture.loadDefault()+        await fixture.viewModel.setNote("Retry note")+        saveStrategy.failSaves = true+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .saveFailed = state else {+            Issue.record("Expected saveFailed state")+            return+        }+        saveStrategy.failSaves = false+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state2 = await fixture.viewModel.state+        guard case .saved = state2 else {+            Issue.record("Expected saved state after retry")+            return+        }+    }++    // MARK: - Save before completion (host dismissal)++    @Test("Repository write occurs before state transitions to saved")+    func repositoryWriteBeforeSaved() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.setNote("Persisted first")+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .saved(let entryID) = state else {+            Issue.record("Expected saved state")+            return+        }+        // Verify the entry exists in the repository+        let entry = try await fixture.repository.entry(id: entryID)+        #expect(entry.note == "Persisted first")+    }++    // MARK: - Pre-save cancellation++    @Test("Cancel before save writes nothing")+    func cancelBeforeSaveWritesNothing() async throws {+        let fixture = try await CaptureStateFixture()+        await fixture.loadDefault()+        await fixture.viewModel.setNote("Will be cancelled")+        await fixture.viewModel.cancel()+        let state = await fixture.viewModel.state+        guard case .cancelled = state else {+            Issue.record("Expected cancelled state, got \(state)")+            return+        }+        let counts = try await fixture.repository.debugCounts()+        #expect(counts.entries == 0)+    }++    // MARK: - Blank manual title validation (Requirement 2.8)++    @Test("Blank manual title keeps save disabled")+    func blankManualTitleDisablesSave() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        // Manual title required but blank+        let canSave = await fixture.viewModel.canSave+        #expect(canSave == false)+    }++    @Test("Non-blank manual title enables save")+    func nonBlankManualTitleEnablesSave() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        await fixture.viewModel.setManualTitle("My Title")+        let canSave = await fixture.viewModel.canSave+        #expect(canSave == true)+    }++    @Test("Save with blank manual title does not persist")+    func saveWithBlankManualTitleDoesNotPersist() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        // Attempt save without providing manual title+        await fixture.viewModel.save(coordinator: fixture.coordinator)+        let counts = try await fixture.repository.debugCounts()+        #expect(counts.entries == 0)+    }++    @Test("Resolved title preserved verbatim without Untitled fallback")+    func resolvedTitleVerbatimNoUntitled() async throws {+        let fixture = try await CaptureStateFixture()+        let safari = SafariPageMetadata(title: "  Spaces Around  ", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        let draft = await fixture.viewModel.currentDraft+        // Title preserved verbatim (not trimmed, not replaced with "Untitled")+        #expect(draft?.captureTitle == "  Spaces Around  ")+    }++    @Test("Non-HTTP URL produces invalidInput state")+    func nonHTTPURLProducesInvalidInput() async throws {+        let fixture = try await CaptureStateFixture()+        let payload = SharePayload(providerURL: "file:///tmp/notes.txt")+        await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)+        let state = await fixture.viewModel.state+        guard case .invalidInput = state else {+            Issue.record("Expected invalidInput state, got \(state)")+            return+        }+    }+}++// MARK: - Test Infrastructure++private final class FailingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var failureEnabled = false++    var failSaves: Bool {+        get { lock.withLock { failureEnabled } }+        set { lock.withLock { failureEnabled = newValue } }+    }++    func save(_ context: ModelContext) throws {+        if failSaves { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++private struct CaptureStateFixture {+    let directory: URL+    let configuration: LibraryConfiguration+    let repository: LibraryRepository+    let coordinator: CaptureCoordinator+    let viewModel: CaptureViewModel++    @MainActor+    init(saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()) async throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismCaptureStateTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+        repository = try await LibraryRepository.open(+            configuration,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000)),+            saveStrategy: saveStrategy+        )+        coordinator = CaptureCoordinator(repository: repository)+        viewModel = CaptureViewModel()+    }++    @MainActor+    func loadDefault() async {+        let safari = SafariPageMetadata(title: "Default Title", locationHref: "https://example.com/default")+        let payload = SharePayload(providerURL: "https://example.com/default", safari: safari)+        await viewModel.load(payload: payload, coordinator: coordinator)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift Added +73 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swiftnew file mode 100644index 0000000..aeabee7--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift@@ -0,0 +1,73 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Configuration shells")+struct ConfigurationShellTests {+    @Test("Personal and Development identities cannot overlap")+    func environmentsAreIsolated() {+        #expect(LibraryEnvironment.personal.appGroupIdentifier != LibraryEnvironment.development.appGroupIdentifier)+    }++    @Test("Store and synchronization paths are fixed")+    func pathsAreStable() {+        let root = URL(filePath: "/tmp/asterism-test", directoryHint: .isDirectory)+        let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)++        #expect(configuration.storeURL.path.hasSuffix("Library/Application Support/Asterism.sqlite"))+        #expect(configuration.lockURL.lastPathComponent == "Asterism.lock")+        #expect(configuration.markerURL.lastPathComponent == "Asterism.initialized")+    }++    @Test("currentEnvironment resolves to development under DEBUG")+    func currentEnvironmentResolvesDevelopment() {+        // In test builds (DEBUG), .current must resolve to .development+        #expect(LibraryEnvironment.current == .development)+    }++    @Test("production(environment:locator:) throws when locator returns nil")+    func productionThrowsOnLocatorFailure() {+        let failing = FailingContainerLocator()+        #expect(throws: LibraryRepositoryError.self) {+            _ = try LibraryConfiguration.production(environment: .development, locator: failing)+        }+    }++    @Test("Locator failure does not create any file at any path")+    func locatorFailureCreatesNoFiles() throws {+        let sentinel = FileManager.default.temporaryDirectory+            .appending(path: "asterism-sentinel-\(UUID())")+        let failing = FailingContainerLocator()++        // Attempt production resolution — must throw+        do {+            _ = try LibraryConfiguration.production(environment: .development, locator: failing)+            Issue.record("Expected production() to throw")+        } catch {+            // Expected+        }++        // Verify no file/directory was created as a fallback+        #expect(!FileManager.default.fileExists(atPath: sentinel.path))+    }++    @Test("production(environment:locator:) returns correct root from locator")+    func productionResolvesFromLocator() throws {+        let root = URL(filePath: "/tmp/asterism-locator-\(UUID())")+        let locator = StubContainerLocator(url: root)+        let config = try LibraryConfiguration.production(environment: .development, locator: locator)+        #expect(config.rootDirectory == root)+        #expect(config.environment == .development)+    }+}++// MARK: - Test helpers++private struct FailingContainerLocator: SharedContainerLocating {+    func containerURL(forAppGroup identifier: String) -> URL? { nil }+}++private struct StubContainerLocator: SharedContainerLocating {+    let url: URL+    func containerURL(forAppGroup identifier: String) -> URL? { url }+}
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json Added +1 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.jsonnew file mode 100644index 0000000..cef1694--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json@@ -0,0 +1 @@+{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"9e81a28e8b352eccadedd5d6d77c7dace28189cfb1b5951fa41397415aa4eebd","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json Added +1 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.jsonnew file mode 100644index 0000000..7733a8d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json@@ -0,0 +1 @@+{"header":{"appBuild":"42","backupFormatVersion":1,"checksum":"00d460b4b0d127ca67dc4e07903ca3795e6fa21dec74450303f54ccc0d9ae7ae","databaseSchemaVersion":1,"entryCount":2,"exportedAt":"2026-07-17T12:00:00.000Z","workCount":1},"payload":{"entries":[{"canonicalURL":"https://example.com/chapter-1","captureTitle":"Chapter 1 — 日本語テスト","captureTitleSource":"safariDocument","chapterTitle":"Chapter 1","chapterTitleProvenance":{"kind":"pattern","patternID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","patternVersion":2},"entryIdentityKey":"https://example.com/manga/123/1","firstCapturedAt":"2026-07-01T10:00:00.000Z","hostname":"example.com","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-01T10:00:00.000Z","modifiedAt":"2026-07-02T15:30:00.500Z","note":"Great chapter with émojis 🎉 and\nnewlines","rating":"up","rawURL":"https://example.com/manga/123/1?utm_source=twitter","workAssignmentProvenance":{"kind":"manual","patternID":null,"patternVersion":null},"workID":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},{"canonicalURL":null,"captureTitle":"Untitled Page","captureTitleSource":"manual","chapterTitle":null,"chapterTitleProvenance":{"kind":"none","patternID":null,"patternVersion":null},"entryIdentityKey":"https://other.test/article","firstCapturedAt":"2026-07-03T08:15:00.123Z","hostname":"other.test","id":"22222222-2222-2222-2222-222222222222","identityKeyVersion":1,"intentionallyUnattached":true,"lastSharedAt":"2026-07-03T08:15:00.123Z","modifiedAt":"2026-07-03T08:15:00.123Z","note":"","rating":null,"rawURL":"https://other.test/article","workAssignmentProvenance":{"kind":"manual","patternID":null,"patternVersion":null},"workID":null}],"sites":[{"displayName":"example.com","hostname":"example.com","junkSuffixRule":{"anchors":[{"offset":1,"origin":"end"}],"version":1},"mode":"taught","patternIDs":["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","cccccccc-cccc-cccc-cccc-cccccccccccc"],"urlIdentityRule":{"component":"pathSegment","offset":1,"origin":"start","queryName":null,"version":1}},{"displayName":"other.test","hostname":"other.test","junkSuffixRule":null,"mode":"untaught","patternIDs":[],"urlIdentityRule":null}],"titlePatterns":[{"createdAt":"2026-06-15T12:00:00.000Z","id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","isActive":true,"junkAnchors":[{"offset":0,"origin":"end"},{"offset":2,"origin":"start"}],"siteHostname":"example.com","version":2,"workAnchor":{"length":2,"offset":0,"origin":"start"}},{"createdAt":"2026-06-10T08:00:00.000Z","id":"cccccccc-cccc-cccc-cccc-cccccccccccc","isActive":false,"junkAnchors":[],"siteHostname":"example.com","version":1,"workAnchor":{"length":1,"offset":1,"origin":"end"}}],"works":[{"createdAt":"2026-07-01T09:00:00.000Z","displayTitle":"My Manga Series","entryIDs":["11111111-1111-1111-1111-111111111111"],"genericNotes":"Notes about the series","genreTags":["fantasy","action"],"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","lastParsedTitle":"My Manga","modifiedAt":"2026-07-02T15:30:00.500Z","siteHostname":"example.com","titleProvenance":"manual","type":"toon","urlIdentity":null,"workURL":"https://example.com/manga/123"}]}}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityNormalizationTests.swift Added +100 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityNormalizationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityNormalizationTests.swiftnew file mode 100644index 0000000..1e8a4a6--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityNormalizationTests.swift@@ -0,0 +1,100 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Entry identity and hostname normalization")+struct IdentityNormalizationTests {+    @Test("Normative V1 vectors")+    func normativeVectors() throws {+        let vectors = [+            ("HTTP://Example.COM:80/a?utm_source=x&b=1#F", "http://example.com/a?b=1#F"),+            ("https://e.test/p?%75tm_source=x&a=1", "https://e.test/p?%75tm_source=x&a=1"),+            ("https://e.test/p?utm_x=1&&a=2&", "https://e.test/p?&a=2&"),+            ("https://u:p@[2001:DB8::1]:443/a", "https://u:p@[2001:db8::1]/a"),+            ("https://e.test:0443/a?fbclid=x", "https://e.test/a"),+        ]++        for (raw, expected) in vectors {+            #expect(try EntryIdentityNormalizer.key(forRawURL: raw) == expected)+        }+    }++    @Test("Only exact tracker names are removed without percent decoding")+    func exactTrackerMatching() throws {+        let raw = "https://EXAMPLE.com/P%2fQ?A=1&UTM_Source=x&xutm_source=keep&fbclid=x&fbclid_extra=keep&gClId=y&%66bclid=keep&a=2&a=3#Frag%2fCase"+        let expected = "https://example.com/P%2fQ?A=1&xutm_source=keep&fbclid_extra=keep&%66bclid=keep&a=2&a=3#Frag%2fCase"+        #expect(try EntryIdentityNormalizer.key(forRawURL: raw) == expected)+    }++    @Test("Protected slices and query component structure remain byte-for-byte")+    func protectedSlices() throws {+        let raw = "https://Example.COM/a//B;%2f?&a=&=value&&utm_x=gone&&#Keep%2FThis"+        let result = try EntryIdentityNormalizer.key(forRawURL: raw)+        #expect(result == "https://example.com/a//B;%2f?&a=&=value&&&#Keep%2FThis")+        #expect(try EntryIdentityNormalizer.key(forRawURL: result) == result)+    }++    @Test("Numeric default ports use numeric equality")+    func numericPorts() throws {+        #expect(try EntryIdentityNormalizer.key(forRawURL: "http://e.test:00080/a") == "http://e.test/a")+        #expect(try EntryIdentityNormalizer.key(forRawURL: "https://e.test:00443/a") == "https://e.test/a")+        #expect(try EntryIdentityNormalizer.key(forRawURL: "https://e.test:80/a") == "https://e.test:80/a")+        #expect(try EntryIdentityNormalizer.key(forRawURL: "http://e.test:443/a") == "http://e.test:443/a")+    }++    @Test("Unsupported or malformed URLs fail with context")+    func invalidURLs() {+        for value in ["", "ftp://example.com/a", "https:///missing-host", "not a URL"] {+            #expect(throws: IdentityNormalizationError.self) {+                try EntryIdentityNormalizer.key(forRawURL: value)+            }+        }+    }++    @Test("URL hostnames lowercase and remove exactly one terminal DNS dot")+    func URLHostnames() throws {+        #expect(try HostnameNormalizer.fromRawURL("https://EXAMPLE.COM./a") == "example.com")+        #expect(try HostnameNormalizer.fromRawURL("https://Sub.Example.COM/a") == "sub.example.com")+        #expect(try HostnameNormalizer.fromRawURL("https://[2001:DB8::1]/a") == "2001:db8::1")+    }++    @Test("Bare hostnames reject URL syntax and normalize terminal dots")+    func bareHostnames() throws {+        #expect(try HostnameNormalizer.bare(" Example.COM. ") == "example.com")+        #expect(try HostnameNormalizer.bare("xn--bcher-kva.example") == "xn--bcher-kva.example")+        for value in ["", "   ", "https://example.com", "user@example.com", "example.com:443", "example.com/path", "example.com?x=1", ".", "example..com"] {+            #expect(throws: HostnameNormalizationError.self) {+                try HostnameNormalizer.bare(value)+            }+        }+    }++    @Test("Seeded generated URLs preserve all non-tracker query bytes")+    func seededProperties() throws {+        var generator = SeededGenerator(seed: 0xA57E_2157)+        let trackerNames = ["utm_source", "FBCLID", "gclid", "dclid", "msclkid", "gbraid", "wbraid", "mc_cid", "mc_eid"]+        for index in 0..<200 {+            let token = String(generator.next(), radix: 16)+            let tracker = trackerNames[Int(generator.next() % UInt64(trackerNames.count))]+            let raw = "HTTPS://Example.COM:0443/Path%2F\(index)?keep=\(token)&\(tracker)=drop&keep=\(token)#F\(index)"+            let expected = "https://example.com/Path%2F\(index)?keep=\(token)&keep=\(token)#F\(index)"+            let key = try EntryIdentityNormalizer.key(forRawURL: raw)+            #expect(key == expected)+            #expect(try EntryIdentityNormalizer.key(forRawURL: key) == key)+        }+    }+}++private struct SeededGenerator: RandomNumberGenerator {+    private var state: UInt64++    init(seed: UInt64) { state = seed }++    mutating func next() -> UInt64 {+        state &+= 0x9E37_79B9_7F4A_7C15+        var value = state+        value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9+        value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB+        return value ^ (value >> 31)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift Added +321 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swiftnew file mode 100644index 0000000..7cb396c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift@@ -0,0 +1,321 @@+import Foundation+import Testing+@testable import AsterismCore++// MARK: - Task 14: Bounded network metadata acquisition tests++@Suite("Bounded network metadata acquisition", .serialized)+struct PageTitleFetcherTests {++    // MARK: - Non-Safari-only invocation++    @Test("Fetcher used only for non-Safari shares through CaptureCoordinator")+    func fetcherNotCalledWithSafari() async throws {+        let fetcher = SpyPageTitleFetcher()+        let fixture = try await FetcherTestFixture(fetcher: fetcher)+        let safari = SafariPageMetadata(title: "Title", locationHref: "https://example.com/page")+        let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+        _ = try await fixture.coordinator.prepare(payload)+        #expect(fetcher.callCount == 0)+    }++    @Test("Fetcher invoked for non-Safari payload without host title")+    func fetcherCalledWithoutSafari() async throws {+        let fetcher = SpyPageTitleFetcher()+        fetcher.response = FetchedPageMetadata(title: "Fetched", canonicalCandidates: [])+        let fixture = try await FetcherTestFixture(fetcher: fetcher)+        let payload = SharePayload(providerURL: "https://example.com/page")+        let result = try await fixture.coordinator.prepare(payload)+        #expect(fetcher.callCount == 1)+        #expect(result.resolvedTitle == "Fetched")+        #expect(result.titleSource == .networkFetch)+    }++    // MARK: - Redirect-wide deadline++    @Test("Three-second deadline applies across entire redirect chain")+    func redirectChainDeadline() async throws {+        // PageTitleFetcher must use one monotonic 3-second deadline for all redirects+        let fetcher = BoundedPageTitleFetcher()+        #expect(fetcher.deadline == .seconds(3))+    }++    // MARK: - Exact 65,536-byte boundary++    @Test("Response body limited to exactly 65,536 decoded bytes")+    func exactByteBoundary() async throws {+        let fetcher = BoundedPageTitleFetcher()+        #expect(fetcher.maxBodyBytes == 65_536)+    }++    @Test("Title extracted from content within byte limit")+    func titleWithinByteLimit() async throws {+        let html = "<html><head><title>Within Limit</title></head><body></body></html>"+        let scanner = HeadMetadataScanner()+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "Within Limit")+    }++    @Test("Title not extracted when it starts beyond byte limit")+    func titleBeyondByteLimit() async throws {+        // Generate a head that pushes the title past the 65,536 boundary+        let padding = String(repeating: " ", count: 65_530)+        let html = "<html><head>\(padding)<title>Late Title</title></head>"+        let data = Data(html.utf8)+        let scanner = HeadMetadataScanner()+        let result = scanner.scan(Data(data.prefix(65_536)))+        #expect(result.title == nil)+    }++    // MARK: - MIME/status handling++    @Test("text/html MIME type accepted")+    func textHtmlAccepted() async throws {+        let result = BoundedPageTitleFetcher.isAcceptableMIME("text/html")+        #expect(result == true)+    }++    @Test("application/xhtml+xml MIME type accepted")+    func xhtmlAccepted() async throws {+        let result = BoundedPageTitleFetcher.isAcceptableMIME("application/xhtml+xml")+        #expect(result == true)+    }++    @Test("Absent MIME type accepted (nil)")+    func absentMIMEAccepted() async throws {+        let result = BoundedPageTitleFetcher.isAcceptableMIME(nil)+        #expect(result == true)+    }++    @Test("application/json MIME type rejected")+    func jsonMIMERejected() async throws {+        let result = BoundedPageTitleFetcher.isAcceptableMIME("application/json")+        #expect(result == false)+    }++    @Test("image/png MIME type rejected")+    func imageMIMERejected() async throws {+        let result = BoundedPageTitleFetcher.isAcceptableMIME("image/png")+        #expect(result == false)+    }++    // MARK: - Charset handling++    @Test("HTTP charset used over BOM and meta when present and valid")+    func httpCharsetPriority() async throws {+        let scanner = HeadMetadataScanner()+        // Latin1-encoded title with HTTP charset=iso-8859-1+        let latin1Bytes: [UInt8] = Array("<html><head><title>Caf\u{e9}</title></head>".utf8)+        let result = scanner.scan(Data(latin1Bytes), httpCharset: "utf-8")+        // With UTF-8 charset, the bytes should decode differently+        #expect(result.title != nil)+    }++    @Test("UTF-8 BOM prioritized over meta charset")+    func bomOverMeta() async throws {+        let scanner = HeadMetadataScanner()+        let bom: [UInt8] = [0xEF, 0xBB, 0xBF]+        let html = "<html><head><meta charset=\"iso-8859-1\"><title>BOM Test</title></head>"+        var data = Data(bom)+        data.append(Data(html.utf8))+        let result = scanner.scan(data)+        #expect(result.title == "BOM Test")+    }++    @Test("Meta charset within first 1024 bytes used for decoding")+    func metaCharsetFirst1024() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><meta charset=\"utf-8\"><title>Meta Test</title></head>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "Meta Test")+    }++    // MARK: - Head scanner: malformed/chunked scenarios++    @Test("Scanner stops at </head> tag")+    func stopsAtHeadClose() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><title>Real</title></head><body><title>Fake</title></body></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "Real")+    }++    @Test("Scanner handles uppercase tags case-insensitively")+    func caseInsensitiveTags() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<HTML><HEAD><TITLE>Upper Case</TITLE></HEAD></HTML>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "Upper Case")+    }++    @Test("Scanner handles mixed case in link rel attributes")+    func mixedCaseCanonicalRel() async throws {+        let scanner = HeadMetadataScanner()+        let html = """+        <html><head>+        <link REL="Canonical" href="https://example.com/canonical">+        <title>Page</title>+        </head></html>+        """+        let result = scanner.scan(Data(html.utf8))+        #expect(result.canonicalCandidates == ["https://example.com/canonical"])+    }++    @Test("Scanner extracts all canonical candidates in document order")+    func multipleCanonicals() async throws {+        let scanner = HeadMetadataScanner()+        let html = """+        <html><head>+        <link rel="canonical" href="https://example.com/first">+        <link rel="canonical" href="https://example.com/second">+        <title>Page</title>+        </head></html>+        """+        let result = scanner.scan(Data(html.utf8))+        #expect(result.canonicalCandidates == ["https://example.com/first", "https://example.com/second"])+    }++    // MARK: - Entity decoding++    @Test("Scanner decodes numeric entities in title")+    func numericEntities() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><title>A&#38;B &#x26; C</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "A&B & C")+    }++    @Test("Scanner decodes named entities: amp, lt, gt, quot, apos")+    func namedEntities() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><title>&amp; &lt; &gt; &quot; &apos;</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "& < > \" '")+    }++    @Test("Unknown named entities remain literal")+    func unknownEntitiesLiteral() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><title>&nbsp; &mdash; text</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "&nbsp; &mdash; text")+    }++    // MARK: - Invalid first canonical links++    @Test("Invalid canonical candidates skipped, first valid wins")+    func invalidCanonicalsSkipped() async throws {+        let scanner = HeadMetadataScanner()+        let html = """+        <html><head>+        <link rel="canonical" href="">+        <link rel="canonical" href="https://valid.example/page">+        <title>Page</title>+        </head></html>+        """+        let result = scanner.scan(Data(html.utf8))+        // Scanner returns all candidates in document order; validation happens in coordinator+        #expect(result.canonicalCandidates == ["", "https://valid.example/page"])+    }++    // MARK: - Manual fallback for non-Safari++    @Test("Non-Safari fetch returning nil title falls to manual")+    func nilFetchTitleFallsToManual() async throws {+        let fetcher = SpyPageTitleFetcher()+        fetcher.response = FetchedPageMetadata(title: nil, canonicalCandidates: [])+        let fixture = try await FetcherTestFixture(fetcher: fetcher)+        let payload = SharePayload(providerURL: "https://example.com/page")+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == nil)+        #expect(result.titleSource == .manual)+    }++    @Test("Non-Safari fetch returning blank title falls to manual")+    func blankFetchTitleFallsToManual() async throws {+        let fetcher = SpyPageTitleFetcher()+        fetcher.response = FetchedPageMetadata(title: "   ", canonicalCandidates: [])+        let fixture = try await FetcherTestFixture(fetcher: fetcher)+        let payload = SharePayload(providerURL: "https://example.com/page")+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == nil)+        #expect(result.titleSource == .manual)+    }++    // MARK: - Quoted and unquoted attributes++    @Test("Scanner handles unquoted href attribute")+    func unquotedHref() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><link rel=canonical href=https://example.com/unquoted><title>T</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.canonicalCandidates.first == "https://example.com/unquoted")+    }++    @Test("Scanner handles single-quoted attributes")+    func singleQuotedHref() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><link rel='canonical' href='https://example.com/single'><title>T</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.canonicalCandidates.first == "https://example.com/single")+    }++    // MARK: - Whitespace-tokenized rel attribute++    @Test("Canonical detected when rel contains multiple whitespace-separated tokens")+    func multiTokenRel() async throws {+        let scanner = HeadMetadataScanner()+        let html = """+        <html><head>+        <link rel="alternate canonical" href="https://example.com/multi">+        <title>T</title>+        </head></html>+        """+        let result = scanner.scan(Data(html.utf8))+        #expect(result.canonicalCandidates == ["https://example.com/multi"])+    }++    // MARK: - First nonblank title returned++    @Test("First nonblank title element is returned")+    func firstNonblankTitle() async throws {+        let scanner = HeadMetadataScanner()+        let html = "<html><head><title></title><title>Second</title></head></html>"+        let result = scanner.scan(Data(html.utf8))+        #expect(result.title == "Second")+    }+}++// MARK: - Test Infrastructure++private struct FetcherTestFixture {+    let repository: LibraryRepository+    let coordinator: CaptureCoordinator++    init(fetcher: any PageTitleFetching) async throws {+        let directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismFetcherTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+        repository = try await LibraryRepository.open(+            configuration,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))+        )+        coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)+    }+}++private final class SpyPageTitleFetcher: PageTitleFetching, @unchecked Sendable {+    private let lock = NSLock()+    private var _callCount = 0+    var response: FetchedPageMetadata? = nil+    var shouldThrow = false++    var callCount: Int { lock.withLock { _callCount } }++    func fetch(url: String) async throws -> FetchedPageMetadata? {+        lock.withLock { _callCount += 1 }+        if shouldThrow { throw URLError(.timedOut) }+        return response+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift Added +200 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swiftnew file mode 100644index 0000000..9710f87--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift@@ -0,0 +1,200 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Cross-process lock and repository bootstrap", .serialized)+struct RepositoryBootstrapTests {+    @Test("Shared leases coexist and block exclusive leases until release")+    func lockContention() async throws {+        let directory = try TemporaryDirectory()+        let lockURL = directory.url.appending(path: "library.lock")+        var first: LockLease? = try await CrossProcessLibraryLock.acquire(mode: .shared, at: lockURL, timeout: .seconds(1))+        var second: LockLease? = try await CrossProcessLibraryLock.acquire(mode: .shared, at: lockURL, timeout: .seconds(1))+        #expect(first != nil)+        #expect(second != nil)++        await #expect(throws: LibraryRepositoryError.self) {+            try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: lockURL, timeout: .milliseconds(100))+        }+        first = nil+        second = nil+        let exclusive = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: lockURL, timeout: .seconds(1))+        _ = exclusive+    }++    @Test("Lock acquisition responds to cancellation")+    func lockCancellation() async throws {+        let directory = try TemporaryDirectory()+        let lockURL = directory.url.appending(path: "library.lock")+        let held = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: lockURL, timeout: .seconds(1))+        let waiter = Task {+            try await CrossProcessLibraryLock.acquire(mode: .shared, at: lockURL, timeout: .seconds(5))+        }+        try await Task.sleep(for: .milliseconds(75))+        waiter.cancel()+        await #expect(throws: CancellationError.self) { try await waiter.value }+        _ = held+    }++    @Test("A terminated helper process releases its lease")+    func processDeathReleasesLock() async throws {+        let directory = try TemporaryDirectory()+        let lockURL = directory.url.appending(path: "library.lock")+        let readyURL = directory.url.appending(path: "ready")+        let helper = try HelperProcess(arguments: ["hold-lock", lockURL.path, "exclusive", readyURL.path, "10000"])+        try helper.run()+        try waitForFile(readyURL)++        await #expect(throws: LibraryRepositoryError.self) {+            try await CrossProcessLibraryLock.acquire(mode: .shared, at: lockURL, timeout: .milliseconds(100))+        }+        helper.terminate()+        helper.waitUntilExit()+        let lease = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: lockURL, timeout: .seconds(1))+        _ = lease+    }++    @Test("Personal and Development resolve isolated roots and stores")+    func configurationIsolation() {+        let base = URL(filePath: "/tmp/configuration-isolation", directoryHint: .isDirectory)+        let personal = LibraryConfiguration(rootDirectory: base.appending(path: "personal"), environment: .personal)+        let development = LibraryConfiguration(rootDirectory: base.appending(path: "development"), environment: .development)+        #expect(personal.environment.appGroupIdentifier != development.environment.appGroupIdentifier)+        #expect(personal.storeURL != development.storeURL)+        #expect(personal.lockURL != development.lockURL)+        #expect(personal.markerURL != development.markerURL)+    }++    @Test("First open creates and validates a marked V1 library")+    func firstOpen() async throws {+        let directory = try TemporaryDirectory()+        let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        let repository = try await LibraryRepository.open(configuration)+        #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))+        #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+        #expect(try await repository.debugCounts() == LibraryRecordCounts(entries: 0, works: 0, sites: 0, titlePatterns: 0))+    }++    @Test("Marker without store fails closed and creates no replacement")+    func markerWithoutStore() async throws {+        let directory = try TemporaryDirectory()+        let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        try Data().write(to: configuration.markerURL)++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.open(configuration)+        }+        #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+    }++    @Test("Valid store without marker is recovered after validation")+    func storeWithoutMarker() async throws {+        let directory = try TemporaryDirectory()+        let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        try runHelperAndRequireSuccess(["open", directory.url.path, "development"])+        try FileManager.default.removeItem(at: configuration.markerURL)++        let repository = try await LibraryRepository.open(configuration)+        #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+        #expect(try await repository.debugCounts().entries == 0)+    }++    @Test("Corrupt established store fails closed without deleting evidence")+    func corruptStore() async throws {+        let directory = try TemporaryDirectory()+        let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        try FileManager.default.createDirectory(at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        let evidence = Data("not a sqlite store".utf8)+        try evidence.write(to: configuration.storeURL)+        try Data().write(to: configuration.markerURL)++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.open(configuration)+        }+        #expect(try Data(contentsOf: configuration.storeURL) == evidence)+        #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+    }++    @Test("Simultaneous helper bootstraps converge on one healthy store")+    func helperBootstrapRace() async throws {+        let directory = try TemporaryDirectory()+        let first = try HelperProcess(arguments: ["open", directory.url.path, "development"])+        let second = try HelperProcess(arguments: ["open", directory.url.path, "development"])+        try first.run()+        try second.run()+        first.waitUntilExit()+        second.waitUntilExit()+        #expect(first.terminationStatus == 0)+        #expect(second.terminationStatus == 0)++        let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        let repository = try await LibraryRepository.open(configuration)+        #expect(try await repository.debugCounts() == .zero)+    }++    @Test("Repository retains its container and returns coherent count snapshots")+    func retainedContainerAndCoherentRead() async throws {+        let directory = try TemporaryDirectory()+        let repository = try await LibraryRepository.open(+            LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        )+        for _ in 0..<10 {+            #expect(try await repository.debugCounts() == .zero)+        }+    }+}++private final class TemporaryDirectory {+    let url: URL++    init() throws {+        url = FileManager.default.temporaryDirectory.appending(path: "AsterismTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+    }++    deinit { try? FileManager.default.removeItem(at: url) }+}++private final class HelperProcess: @unchecked Sendable {+    private let process = Process()++    init(arguments: [String]) throws {+        process.executableURL = try helperExecutableURL()+        process.arguments = arguments+        process.standardOutput = FileHandle.nullDevice+        process.standardError = FileHandle.nullDevice+    }++    var terminationStatus: Int32 { process.terminationStatus }++    func run() throws { try process.run() }+    func terminate() { process.terminate() }+    func waitUntilExit() { process.waitUntilExit() }+}++private func helperExecutableURL() throws -> URL {+    let packageRoot = URL(filePath: #filePath)+        .deletingLastPathComponent()+        .deletingLastPathComponent()+        .deletingLastPathComponent()+    let url = packageRoot.appending(path: ".build/debug/AsterismStoreTestHelper")+    guard FileManager.default.isExecutableFile(atPath: url.path) else {+        throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: url.path])+    }+    return url+}++private func waitForFile(_ url: URL) throws {+    let deadline = Date().addingTimeInterval(3)+    while !FileManager.default.fileExists(atPath: url.path) {+        guard Date() < deadline else { throw CocoaError(.fileReadUnknown) }+        Thread.sleep(forTimeInterval: 0.01)+    }+}++private func runHelperAndRequireSuccess(_ arguments: [String]) throws {+    let process = try HelperProcess(arguments: arguments)+    try process.run()+    process.waitUntilExit()+    guard process.terminationStatus == 0 else { throw CocoaError(.executableLoad) }+}
Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift Added +236 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swiftnew file mode 100644index 0000000..9e3598d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift@@ -0,0 +1,236 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++@Suite("Repository capture, Entry curation, and deletion", .serialized)+struct RepositoryCaptureTests {+    @Test("Capture atomically creates first Site and reports existing untaught Sites")+    func firstAndExistingSiteCapture() async throws {+        let fixture = try await CaptureRepositoryFixture()+        #expect(try await fixture.repository.siteStatus(forRawURL: "https://Example.COM./one") == .newSite)++        let first = try await fixture.repository.capture(.fixture(rawURL: "https://Example.COM./one"))+        #expect(first.hostname == "example.com")+        #expect(first.chapterTitle == nil)+        #expect(first.chapterTitleProvenance.kind == .none)+        #expect(first.workID == nil)+        #expect(first.workAssignmentProvenance.kind == .none)+        #expect(first.intentionallyUnattached == false)+        #expect(try await fixture.repository.siteStatus(forRawURL: "https://example.com/two") == .existingUntaught)++        _ = try await fixture.repository.capture(.fixture(rawURL: "https://example.com/two"))+        #expect(try await fixture.repository.debugCounts() == LibraryRecordCounts(entries: 2, works: 0, sites: 1, titlePatterns: 0))+    }++    @Test("Duplicate raw URLs remain separate Entries")+    func duplicateRawURLs() async throws {+        let fixture = try await CaptureRepositoryFixture()+        let draft = CaptureDraft.fixture(rawURL: "https://example.com/repeated?utm_source=x")+        let first = try await fixture.repository.capture(draft)+        let second = try await fixture.repository.capture(draft)+        #expect(first.id != second.id)+        #expect(first.entryIdentityKey == second.entryIdentityKey)+        #expect(try await fixture.repository.debugCounts().entries == 2)+    }++    @Test("Capture and curation obey timestamp boundaries")+    func editTimestamps() async throws {+        let initial = Date(timeIntervalSince1970: 1_721_000_000.123_987)+        let clock = MutableTestClock(initial)+        let fixture = try await CaptureRepositoryFixture(clock: clock)+        let captured = try await fixture.repository.capture(.fixture(note: "before", rating: nil))+        #expect(captured.firstCapturedAt == MillisecondInstant.quantize(initial))+        #expect(captured.lastSharedAt == captured.firstCapturedAt)+        #expect(captured.modifiedAt == captured.firstCapturedAt)++        let editedAt = initial.addingTimeInterval(60.789_987)+        clock.set(editedAt)+        try await fixture.repository.updateEntry(id: captured.id, note: "after", rating: .down)+        let edited = try await fixture.repository.entry(id: captured.id)+        #expect(edited.note == "after")+        #expect(edited.rating == .down)+        #expect(edited.firstCapturedAt == captured.firstCapturedAt)+        #expect(edited.lastSharedAt == captured.lastSharedAt)+        #expect(edited.modifiedAt == MillisecondInstant.quantize(editedAt))+    }++    @Test("Recent snapshots sort deterministically and group by supplied calendar")+    func recentGrouping() async throws {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(secondsFromGMT: 0)!+        let clock = MutableTestClock(Date(timeIntervalSince1970: 1_721_001_600))+        let fixture = try await CaptureRepositoryFixture(clock: clock)+        let first = try await fixture.repository.capture(.fixture(title: "first", rawURL: "https://a.example/1"))+        let second = try await fixture.repository.capture(.fixture(title: "second", rawURL: "https://a.example/2"))+        clock.set(Date(timeIntervalSince1970: 1_720_915_200))+        let older = try await fixture.repository.capture(.fixture(title: "older", rawURL: "https://a.example/3"))++        let groups = try await fixture.repository.recentEntries(calendar: calendar)+        #expect(groups.count == 2)+        let expectedTieOrder = [first, second].sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }+        #expect(groups[0].entries.map(\.id) == expectedTieOrder.map(\.id))+        #expect(groups[1].entries.map(\.id) == [older.id])+    }++    @Test("Plain deletion retains an emptied Work and updates its timestamp")+    func deleteRetainsEmptyWork() async throws {+        let clock = MutableTestClock(Date(timeIntervalSince1970: 1_721_000_000))+        let fixture = try await CaptureRepositoryFixture(clock: clock)+        let entry = try await fixture.repository.capture(.fixture())+        let workID = UUID()+        try await seedWorkAssignment(configuration: fixture.configuration, entryID: entry.id, workID: workID, timestamp: clock.now())+        clock.set(Date(timeIntervalSince1970: 1_721_000_100))++        try await fixture.repository.deleteEntry(id: entry.id)+        #expect(try await fixture.repository.debugCounts() == LibraryRecordCounts(entries: 0, works: 1, sites: 1, titlePatterns: 0))+        let modifiedAt = try await fetchWorkModifiedAt(configuration: fixture.configuration, workID: workID)+        #expect(modifiedAt == clock.now())+    }++    @Test("Failed compound saves discard fresh contexts and persist nothing")+    func captureFailureRollback() async throws {+        let saveStrategy = ControlledSaveStrategy()+        let fixture = try await CaptureRepositoryFixture(saveStrategy: saveStrategy)+        saveStrategy.failSaves = true+        await #expect(throws: LibraryRepositoryError.self) {+            try await fixture.repository.capture(.fixture())+        }+        #expect(try await fixture.repository.debugCounts() == .zero)+    }++    @Test("Failed deletion preserves Entry and prior Work state")+    func deletionFailureRollback() async throws {+        let saveStrategy = ControlledSaveStrategy()+        let fixture = try await CaptureRepositoryFixture(saveStrategy: saveStrategy)+        let entry = try await fixture.repository.capture(.fixture())+        saveStrategy.failSaves = true++        await #expect(throws: LibraryRepositoryError.self) {+            try await fixture.repository.deleteEntry(id: entry.id)+        }+        #expect(try await fixture.repository.entry(id: entry.id).id == entry.id)+        #expect(try await fixture.repository.debugCounts().entries == 1)+    }++    @Test("Capture validates title, raw URL, and canonical URL at its boundary")+    func captureValidation() async throws {+        let fixture = try await CaptureRepositoryFixture()+        let invalid = [+            CaptureDraft.fixture(title: "   "),+            CaptureDraft.fixture(rawURL: "file:///tmp/note"),+            CaptureDraft.fixture(canonicalURLString: "ftp://example.com/work"),+        ]+        for draft in invalid {+            await #expect(throws: LibraryRepositoryError.self) {+                try await fixture.repository.capture(draft)+            }+        }+        #expect(try await fixture.repository.debugCounts() == .zero)+    }+}++private extension CaptureDraft {+    static func fixture(+        title: String = "Chapter 1",+        rawURL: String = "https://example.com/chapter-1",+        canonicalURLString: String? = nil,+        note: String = "",+        rating: Rating? = nil+    ) -> CaptureDraft {+        CaptureDraft(+            captureTitle: title,+            captureTitleSource: .manual,+            rawURLString: rawURL,+            canonicalURLString: canonicalURLString,+            note: note,+            rating: rating+        )+    }+}++private final class MutableTestClock: RepositoryClock, @unchecked Sendable {+    private let lock = NSLock()+    private var value: Date++    init(_ value: Date) { self.value = value }+    func set(_ value: Date) { lock.withLock { self.value = value } }+    func now() -> Date { lock.withLock { MillisecondInstant.quantize(value) } }+}++private final class ControlledSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var failureEnabled = false++    var failSaves: Bool {+        get { lock.withLock { failureEnabled } }+        set { lock.withLock { failureEnabled = newValue } }+    }++    func save(_ context: ModelContext) throws {+        if failSaves { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++private struct CaptureRepositoryFixture {+    let directory: CaptureTemporaryDirectory+    let configuration: LibraryConfiguration+    let repository: LibraryRepository++    init(+        clock: any RepositoryClock = FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000)),+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) async throws {+        directory = try CaptureTemporaryDirectory()+        configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+        repository = try await LibraryRepository.open(configuration, clock: clock, saveStrategy: saveStrategy)+    }+}++private final class CaptureTemporaryDirectory {+    let url: URL+    init() throws {+        url = FileManager.default.temporaryDirectory.appending(path: "AsterismCaptureTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+    }+    deinit { try? FileManager.default.removeItem(at: url) }+}++private func seedWorkAssignment(+    configuration: LibraryConfiguration,+    entryID: UUID,+    workID: UUID,+    timestamp: Date+) async throws {+    let lease = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: configuration.lockURL, timeout: .seconds(1))+    _ = lease+    let container = try makeStoreContainer(configuration)+    let context = ModelContext(container)+    var descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == entryID })+    descriptor.fetchLimit = 2+    let entries = try context.fetch(descriptor)+    guard entries.count == 1, let entry = entries.first else { throw CocoaError(.fileReadCorruptFile) }+    let work = Work(id: workID, displayTitle: "Work", siteHostname: entry.hostname, timestamp: timestamp)+    context.insert(work)+    entry.work = work+    try context.save()+}++private func fetchWorkModifiedAt(configuration: LibraryConfiguration, workID: UUID) async throws -> Date {+    let lease = try await CrossProcessLibraryLock.acquire(mode: .shared, at: configuration.lockURL, timeout: .seconds(1))+    _ = lease+    let container = try makeStoreContainer(configuration)+    let context = ModelContext(container)+    var descriptor = FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })+    descriptor.fetchLimit = 2+    let works = try context.fetch(descriptor)+    guard works.count == 1, let work = works.first else { throw CocoaError(.fileReadCorruptFile) }+    return work.modifiedAt+}++private func makeStoreContainer(_ configuration: LibraryConfiguration) throws -> ModelContainer {+    let schema = Schema(versionedSchema: AsterismSchemaV1.self)+    let store = ModelConfiguration("AsterismV1", schema: schema, url: configuration.storeURL, cloudKitDatabase: .none)+    return try ModelContainer(for: schema, migrationPlan: AsterismMigrationPlan.self, configurations: [store])+}
Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift Added +223 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swiftnew file mode 100644index 0000000..808e995--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift@@ -0,0 +1,223 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++@Suite("Repository Works, metadata, and assignment", .serialized)+struct RepositoryWorksTests {+    @Test("Standalone Work creation creates or reuses Site with documented defaults")+    func standaloneCreationDefaults() async throws {+        let fixture = try await WorksRepositoryFixture()+        let work = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "  A Work  ", hostname: " Example.COM. "))+        #expect(work.displayTitle == "  A Work  ")+        #expect(work.siteHostname == "example.com")+        #expect(work.type == .other)+        #expect(work.titleProvenance == .manual)+        #expect(work.genreTags.isEmpty)+        #expect(work.genericNotes.isEmpty)+        #expect(work.lastParsedTitle == nil)+        #expect(work.urlIdentity == nil)+        #expect(work.workURLString == nil)+        #expect(try await fixture.repository.debugCounts() == LibraryRecordCounts(entries: 0, works: 1, sites: 1, titlePatterns: 0))++        _ = try await fixture.repository.capture(.worksFixture(rawURL: "https://example.com/one"))+        _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Another", hostname: "example.com"))+        #expect(try await fixture.repository.debugCounts().sites == 1)+    }++    @Test("Work creation validates title and bare hostname grammar")+    func standaloneValidation() async throws {+        let fixture = try await WorksRepositoryFixture()+        for draft in [+            NewWorkDraft(displayTitle: "   ", hostname: "example.com"),+            NewWorkDraft(displayTitle: "Work", hostname: "https://example.com"),+            NewWorkDraft(displayTitle: "Work", hostname: "example.com:443"),+            NewWorkDraft(displayTitle: "Work", hostname: "example..com"),+        ] {+            await #expect(throws: LibraryRepositoryError.self) { try await fixture.repository.createWork(draft) }+        }+        #expect(try await fixture.repository.debugCounts() == .zero)+    }++    @Test("Works snapshot orders non-empty, empty, and unattached groups deterministically")+    func worksOrdering() async throws {+        let clock = WorksMutableClock(Date(timeIntervalSince1970: 100))+        let fixture = try await WorksRepositoryFixture(clock: clock)+        let firstEmpty = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "First", hostname: "example.com"))+        clock.set(Date(timeIntervalSince1970: 200))+        let secondEmpty = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Second", hostname: "example.com"))+        clock.set(Date(timeIntervalSince1970: 300))+        let assigned = try await fixture.repository.capture(.worksFixture(title: "Assigned", rawURL: "https://example.com/a"))+        try await fixture.repository.moveEntry(assigned.id, to: .existing(firstEmpty.id))+        let unattached = try await fixture.repository.capture(.worksFixture(title: "Loose", rawURL: "https://example.com/b"))++        let snapshot = try await fixture.repository.works()+        #expect(snapshot.works.map(\.id) == [firstEmpty.id, secondEmpty.id])+        #expect(snapshot.works[0].entries.map(\.id) == [assigned.id])+        #expect(snapshot.unattachedEntries.map(\.id) == [unattached.id])+    }++    @Test("Same-host destinations exclude Works from other Sites")+    func sameHostDestinations() async throws {+        let fixture = try await WorksRepositoryFixture()+        let matching = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Matching", hostname: "example.com"))+        _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Other", hostname: "other.example"))+        let entry = try await fixture.repository.capture(.worksFixture(rawURL: "https://example.com/chapter"))++        let destinations = try await fixture.repository.workDestinations(for: entry.id)+        #expect(destinations.map(\.id) == [matching.id])+    }++    @Test("Metadata editing normalizes tags atomically without changing Entry timestamps")+    func metadataEditing() async throws {+        let clock = WorksMutableClock(Date(timeIntervalSince1970: 100))+        let fixture = try await WorksRepositoryFixture(clock: clock)+        let work = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Original", hostname: "example.com"))+        let entry = try await fixture.repository.capture(.worksFixture())+        try await fixture.repository.moveEntry(entry.id, to: .existing(work.id))+        let entryBefore = try await fixture.repository.entry(id: entry.id)+        clock.set(Date(timeIntervalSince1970: 500))++        try await fixture.repository.updateWork(+            id: work.id,+            draft: WorkMetadataDraft(+                displayTitle: "Renamed",+                type: .novel,+                genreTags: [" fantasy ", "", "fantasy", "Fantasy", "action", "action "],+                genericNotes: "Notes"+            )+        )+        let updated = try await fixture.repository.work(id: work.id)+        let entryAfter = try await fixture.repository.entry(id: entry.id)+        #expect(updated.displayTitle == "Renamed")+        #expect(updated.type == .novel)+        #expect(updated.genreTags == ["fantasy", "Fantasy", "action"])+        #expect(updated.genericNotes == "Notes")+        #expect(updated.titleProvenance == .manual)+        #expect(updated.modifiedAt == clock.now())+        #expect(entryAfter.firstCapturedAt == entryBefore.firstCapturedAt)+        #expect(entryAfter.lastSharedAt == entryBefore.lastSharedAt)+        #expect(entryAfter.modifiedAt == entryBefore.modifiedAt)+    }++    @Test("Assignment, no-op selection, and Leave unattached preserve independent provenance")+    func assignmentAndNoOp() async throws {+        let save = WorksControlledSaveStrategy()+        let clock = WorksMutableClock(Date(timeIntervalSince1970: 100))+        let fixture = try await WorksRepositoryFixture(clock: clock, saveStrategy: save)+        let work = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Work", hostname: "example.com"))+        let entry = try await fixture.repository.capture(.worksFixture())+        clock.set(Date(timeIntervalSince1970: 200))+        try await fixture.repository.moveEntry(entry.id, to: .existing(work.id))+        let assigned = try await fixture.repository.entry(id: entry.id)+        #expect(assigned.workID == work.id)+        #expect(assigned.workAssignmentProvenance.kind == .manual)+        #expect(assigned.chapterTitleProvenance.kind == .none)+        #expect(assigned.intentionallyUnattached == false)++        save.failSaves = true+        clock.set(Date(timeIntervalSince1970: 300))+        try await fixture.repository.moveEntry(entry.id, to: .existing(work.id))+        #expect(try await fixture.repository.entry(id: entry.id).modifiedAt == assigned.modifiedAt)+        save.failSaves = false++        try await fixture.repository.moveEntry(entry.id, to: .unattached)+        let loose = try await fixture.repository.entry(id: entry.id)+        #expect(loose.workID == nil)+        #expect(loose.workAssignmentProvenance.kind == .manual)+        #expect(loose.intentionallyUnattached)+        #expect(loose.firstCapturedAt == assigned.firstCapturedAt)+        #expect(loose.lastSharedAt == assigned.lastSharedAt)+    }++    @Test("New Work assignment uses Entry hostname and saves atomically")+    func newWorkAssignment() async throws {+        let fixture = try await WorksRepositoryFixture()+        let entry = try await fixture.repository.capture(.worksFixture(rawURL: "https://EXAMPLE.com/chapter"))+        try await fixture.repository.moveEntry(entry.id, to: .newWork(displayTitle: "Created from Entry"))+        let assigned = try await fixture.repository.entry(id: entry.id)+        let snapshot = try await fixture.repository.works()+        #expect(snapshot.works.count == 1)+        #expect(snapshot.works[0].siteHostname == "example.com")+        #expect(snapshot.works[0].displayTitle == "Created from Entry")+        #expect(assigned.workID == snapshot.works[0].id)+    }++    @Test("Metadata and new-assignment failures leave prior state unchanged")+    func atomicFailures() async throws {+        let save = WorksControlledSaveStrategy()+        let fixture = try await WorksRepositoryFixture(saveStrategy: save)+        let work = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Original", hostname: "example.com"))+        let entry = try await fixture.repository.capture(.worksFixture())+        save.failSaves = true++        await #expect(throws: LibraryRepositoryError.self) {+            try await fixture.repository.updateWork(+                id: work.id,+                draft: WorkMetadataDraft(displayTitle: "Changed", type: .toon, genreTags: ["x"], genericNotes: "changed")+            )+        }+        await #expect(throws: LibraryRepositoryError.self) {+            try await fixture.repository.moveEntry(entry.id, to: .newWork(displayTitle: "Should not exist"))+        }+        #expect(try await fixture.repository.work(id: work.id).displayTitle == "Original")+        #expect(try await fixture.repository.entry(id: entry.id).workID == nil)+        #expect(try await fixture.repository.debugCounts().works == 1)+    }+}++private extension CaptureDraft {+    static func worksFixture(+        title: String = "Chapter",+        rawURL: String = "https://example.com/chapter"+    ) -> CaptureDraft {+        CaptureDraft(captureTitle: title, captureTitleSource: .manual, rawURLString: rawURL)+    }+}++private final class WorksMutableClock: RepositoryClock, @unchecked Sendable {+    private let lock = NSLock()+    private var value: Date+    init(_ value: Date) { self.value = value }+    func set(_ value: Date) { lock.withLock { self.value = value } }+    func now() -> Date { lock.withLock { MillisecondInstant.quantize(value) } }+}++private final class WorksControlledSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var failureEnabled = false+    var failSaves: Bool {+        get { lock.withLock { failureEnabled } }+        set { lock.withLock { failureEnabled = newValue } }+    }+    func save(_ context: ModelContext) throws {+        if failSaves { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++private struct WorksRepositoryFixture {+    let directory: WorksTemporaryDirectory+    let repository: LibraryRepository++    init(+        clock: any RepositoryClock = FixedRepositoryClock(Date(timeIntervalSince1970: 100)),+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) async throws {+        directory = try WorksTemporaryDirectory()+        repository = try await LibraryRepository.open(+            LibraryConfiguration(rootDirectory: directory.url, environment: .development),+            clock: clock,+            saveStrategy: saveStrategy+        )+    }+}++private final class WorksTemporaryDirectory {+    let url: URL+    init() throws {+        url = FileManager.default.temporaryDirectory.appending(path: "AsterismWorksTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+    }+    deinit { try? FileManager.default.removeItem(at: url) }+}
Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV1Tests.swift Added +189 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV1Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV1Tests.swiftnew file mode 100644index 0000000..b43ae28--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV1Tests.swift@@ -0,0 +1,189 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++@Suite("Asterism schema V1", .serialized)+struct SchemaV1Tests {+    @Test("Schema identity and enum wire values are stable")+    func schemaAndRawValues() {+        #expect(AsterismSchemaV1.versionIdentifier == Schema.Version(1, 0, 0))+        #expect(AsterismMigrationPlan.schemas.count == 1)+        #expect(AsterismMigrationPlan.stages.isEmpty)+        #expect(CaptureTitleSource.allCases.map(\.rawValue) == ["host", "safariDocument", "networkFetch", "manual"])+        #expect(Rating.allCases.map(\.rawValue) == ["up", "down"])+        #expect(WorkType.allCases.map(\.rawValue) == ["novel", "toon", "article", "other"])+        #expect(TitleProvenance.allCases.map(\.rawValue) == ["parsed", "manual"])+        #expect(FieldProvenanceKind.allCases.map(\.rawValue) == ["none", "pattern", "manual"])+        #expect(SiteMode.allCases.map(\.rawValue) == ["untaught", "taught", "articles"])+        #expect(AnchorOrigin.allCases.map(\.rawValue) == ["start", "end"])+        #expect(URLRuleComponent.allCases.map(\.rawValue) == ["pathSegment", "queryItem"])+    }++    @Test("Value objects reject invalid combinations")+    func valueObjectInvariants() throws {+        #expect(throws: ModelInvariantError.self) { try SegmentRangeSpec(origin: .start, offset: -1, length: 1) }+        #expect(throws: ModelInvariantError.self) { try SegmentRangeSpec(origin: .start, offset: 0, length: 0) }+        #expect(throws: ModelInvariantError.self) { try SegmentPositionSpec(origin: .end, offset: -1) }+        #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 0, component: .pathSegment, origin: .start, offset: 0) }+        #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .queryItem, queryName: "   ") }+        #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .pathSegment, queryName: "chapter") }+        #expect(throws: ModelInvariantError.self) { try JunkSuffixRule(version: 0, anchors: []) }+        #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .pattern, patternID: nil, patternVersion: 1) }+        #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .manual, patternID: UUID(), patternVersion: 1) }++        let pathRule = try URLIdentityRule(version: 1, component: .pathSegment, origin: .end, offset: 2)+        #expect(pathRule.queryName == nil)+        let queryRule = try URLIdentityRule(version: 2, component: .queryItem, queryName: "chapter")+        #expect(queryRule.origin == nil)+        #expect(queryRule.offset == nil)+    }++    @Test("Models have documented defaults and nil-to-empty domain collections")+    func defaultsAndCollections() throws {+        let instant = Date(timeIntervalSince1970: 1_721_000_000.123)+        let site = Site(hostname: "example.com")+        let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: instant)+        let entry = Entry(+            captureTitle: "Chapter 1",+            captureTitleSource: .host,+            rawURLString: "https://example.com/1",+            hostname: site.hostname,+            entryIdentityKey: "https://example.com/1",+            timestamp: instant+        )++        #expect(site.displayName == "example.com")+        #expect(site.mode == .untaught)+        #expect(site.patternValues.isEmpty)+        #expect(work.type == .other)+        #expect(work.titleProvenance == .manual)+        #expect(work.entryValues.isEmpty)+        #expect(work.genreTags.isEmpty)+        #expect(work.genericNotes.isEmpty)+        #expect(entry.chapterTitle == nil)+        #expect(entry.chapterTitleProvenance == .none)+        #expect(entry.workAssignmentProvenance == .none)+        #expect(entry.work == nil)+        #expect(entry.note.isEmpty)+        #expect(entry.rating == nil)+        #expect(entry.identityKeyVersion == 1)+        #expect(entry.intentionallyUnattached == false)+    }++    @Test("Optional relationships preserve explicit inverses")+    func optionalRelationshipInverses() throws {+        let fixture = try ModelFixture()+        let timestamp = Date(timeIntervalSince1970: 1_721_000_000)+        let site = Site(hostname: "example.com")+        let anchor = try SegmentRangeSpec(origin: .start, offset: 0, length: 1)+        let pattern = TitlePattern(version: 1, createdAt: timestamp, workAnchor: anchor, site: site)+        let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: timestamp)+        let entry = Entry(+            captureTitle: "Chapter 1",+            captureTitleSource: .manual,+            rawURLString: "https://example.com/1",+            hostname: site.hostname,+            entryIdentityKey: "https://example.com/1",+            timestamp: timestamp,+            work: work+        )+        fixture.context.insert(site)+        fixture.context.insert(pattern)+        fixture.context.insert(work)+        fixture.context.insert(entry)+        try fixture.context.save()++        #expect(work.entryValues.map(\.id) == [entry.id])+        #expect(site.patternValues.map(\.id) == [pattern.id])+        #expect(pattern.site?.hostname == site.hostname)+    }++    @Test("V1 persists every model and excludes generated Item data")+    func persistenceRoundTrip() throws {+        let fixture = try ModelFixture()+        let timestamp = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_721_000_000.123_987))+        let site = Site(hostname: "example.com")+        let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: timestamp)+        let entry = Entry(+            captureTitle: "Chapter 1",+            captureTitleSource: .safariDocument,+            rawURLString: "https://example.com/read?chapter=1",+            canonicalURLString: "https://example.com/work/chapter-1",+            hostname: site.hostname,+            entryIdentityKey: "https://example.com/read?chapter=1",+            timestamp: timestamp,+            note: "Remember this",+            rating: .up,+            work: work+        )+        let pattern = TitlePattern(+            version: 1,+            createdAt: timestamp,+            workAnchor: try SegmentRangeSpec(origin: .start, offset: 0, length: 2),+            junkAnchors: [try SegmentPositionSpec(origin: .end, offset: 0)],+            site: site+        )+        fixture.context.insert(site)+        fixture.context.insert(work)+        fixture.context.insert(entry)+        fixture.context.insert(pattern)+        try fixture.context.save()++        let verification = ModelContext(fixture.container)+        let entries = try verification.fetch(FetchDescriptor<Entry>())+        let works = try verification.fetch(FetchDescriptor<Work>())+        let sites = try verification.fetch(FetchDescriptor<Site>())+        let patterns = try verification.fetch(FetchDescriptor<TitlePattern>())++        #expect(entries.count == 1)+        #expect(entries[0].captureTitle == "Chapter 1")+        #expect(entries[0].note == "Remember this")+        #expect(entries[0].rating == .up)+        #expect(entries[0].work?.id == work.id)+        #expect(entries[0].firstCapturedAt == timestamp)+        #expect(works.count == 1)+        #expect(sites.count == 1)+        #expect(patterns.count == 1)+        #expect(patterns[0].workAnchor.length == 2)+        #expect(patterns[0].junkAnchors.count == 1)+    }++    @Test("Repository clock quantizes once to milliseconds")+    func millisecondClockBoundary() {+        let source = Date(timeIntervalSince1970: 10.123_987)+        let clock = FixedRepositoryClock(source)+        #expect(clock.now() == Date(timeIntervalSince1970: 10.123))+        #expect(MillisecondInstant.isQuantized(clock.now()))+        #expect(MillisecondInstant.quantize(clock.now()) == clock.now())+    }++    @Test("Snapshots are immutable Sendable values")+    func snapshotsAreSendable() {+        func requireSendable<T: Sendable>(_: T.Type) {}+        requireSendable(EntrySnapshot.self)+        requireSendable(WorkSnapshot.self)+        requireSendable(SiteSnapshot.self)+        requireSendable(TitlePatternSnapshot.self)+        requireSendable(WorksSnapshot.self)+        requireSendable(DatedEntryGroup.self)+    }+}++private struct ModelFixture {+    // A ModelContext does not retain its container. Keeping both in the fixture+    // prevents non-deterministic SwiftData use-after-deallocation crashes.+    let container: ModelContainer+    let context: ModelContext++    init() throws {+        let schema = Schema(versionedSchema: AsterismSchemaV1.self)+        let configuration = ModelConfiguration(+            schema: schema,+            isStoredInMemoryOnly: true,+            cloudKitDatabase: .none+        )+        container = try ModelContainer(for: schema, configurations: [configuration])+        context = ModelContext(container)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift Added +265 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swiftnew file mode 100644index 0000000..1c7370e--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift@@ -0,0 +1,265 @@+import Foundation+import Testing+@testable import AsterismCore+++private final class HangingItemProvider: ItemProviderLoading, @unchecked Sendable {+    let hasURL = true+    let hasPropertyList = false+    private let lock = NSLock()+    private var progress: Progress?++    var hasStarted: Bool {+        lock.withLock { progress != nil }+    }++    var wasCancelled: Bool {+        lock.withLock { progress?.isCancelled == true }+    }++    func loadURL(completion: @escaping @Sendable (URL?, (any Error)?) -> Void) -> Progress {+        let value = Progress(totalUnitCount: 1)+        lock.withLock { progress = value }+        return value+    }++    func loadPropertyList(completion: @escaping @Sendable ([String: Any]?, (any Error)?) -> Void) -> Progress {+        Progress(totalUnitCount: 1)+    }+}+// MARK: - Testable fakes for ItemProviderLoading and ExtensionItemProviding++private final class FakeItemProvider: ItemProviderLoading, @unchecked Sendable {+    let hasURL: Bool+    let hasPropertyList: Bool+    private let urlResult: URL?+    private let urlError: (any Error)?+    private let propertyListResult: [String: Any]?+    private let propertyListError: (any Error)?+    private let lock = NSLock()+    private var _loadURLCallCount = 0+    private var _loadPropertyListCallCount = 0+    private var _cancelledProgressCount = 0++    var loadURLCallCount: Int { lock.withLock { _loadURLCallCount } }+    var loadPropertyListCallCount: Int { lock.withLock { _loadPropertyListCallCount } }+    var cancelledProgressCount: Int { lock.withLock { _cancelledProgressCount } }++    init(+        hasURL: Bool = false,+        urlResult: URL? = nil,+        urlError: (any Error)? = nil,+        hasPropertyList: Bool = false,+        propertyListResult: [String: Any]? = nil,+        propertyListError: (any Error)? = nil+    ) {+        self.hasURL = hasURL+        self.urlResult = urlResult+        self.urlError = urlError+        self.hasPropertyList = hasPropertyList+        self.propertyListResult = propertyListResult+        self.propertyListError = propertyListError+    }++    func loadURL(completion: @escaping @Sendable (URL?, (any Error)?) -> Void) -> Progress {+        lock.withLock { _loadURLCallCount += 1 }+        let progress = Progress(totalUnitCount: 1)+        DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { [self] in+            if progress.isCancelled {+                self.lock.withLock { self._cancelledProgressCount += 1 }+                return+            }+            if let error = self.urlError {+                completion(nil, error)+            } else {+                completion(self.urlResult, nil)+            }+            progress.completedUnitCount = 1+        }+        return progress+    }++    func loadPropertyList(completion: @escaping @Sendable ([String: Any]?, (any Error)?) -> Void) -> Progress {+        lock.withLock { _loadPropertyListCallCount += 1 }+        let progress = Progress(totalUnitCount: 1)+        DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { [self] in+            if progress.isCancelled {+                self.lock.withLock { self._cancelledProgressCount += 1 }+                return+            }+            if let error = self.propertyListError {+                completion(nil, error)+            } else {+                completion(self.propertyListResult, nil)+            }+            progress.completedUnitCount = 1+        }+        return progress+    }+}++private struct FakeExtensionItem: ExtensionItemProviding {+    let attributedTitleString: String?+    let providers: [any ItemProviderLoading]+}++// MARK: - SharePayloadExtractor Selection Tests++@Suite("SharePayloadExtractor selection logic", .serialized)+struct SharePayloadExtractorTests {++    @Test("First usable URL provider wins — later providers not loaded")+    func firstUsableProviderWins() async throws {+        let provider1 = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://first.example.com")!)+        let provider2 = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://second.example.com")!)+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [provider1, provider2])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])+        #expect(payload.providerURL == "https://first.example.com")+        // Second provider should not be loaded for URL+        #expect(provider2.loadURLCallCount == 0)+    }++    @Test("Source order preserved — items walked in sequence, failed URL continues to next")+    func sourceOrderPreserved() async throws {+        let provider1 = FakeItemProvider(hasURL: true, urlError: URLError(.unknown))+        let provider2 = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://fallback.example.com")!)+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [provider1, provider2])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])+        // First provider failed, second wins+        #expect(payload.providerURL == "https://fallback.example.com")+        #expect(provider1.loadURLCallCount == 1)+        #expect(provider2.loadURLCallCount == 1)+    }++    @Test("Nonblank host title extracted from attributedTitleString")+    func nonblankHostTitleExtracted() async throws {+        let provider = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://example.com")!)+        let item = FakeExtensionItem(attributedTitleString: "  Page Title  ", providers: [provider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])+        #expect(payload.hostTitle == "  Page Title  ")+    }++    @Test("Blank host title treated as absent")+    func blankHostTitleTreatedAsAbsent() async throws {+        let provider = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://example.com")!)+        let item = FakeExtensionItem(attributedTitleString: "   ", providers: [provider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])+        #expect(payload.hostTitle == nil)+    }++    @Test("Safari property-list evidence loaded independently even after URL selection")+    func safariEvidenceIndependent() async throws {+        let jsResults: [String: Any] = [+            safariPreprocessingResultsKey: [+                "locationHref": "https://safari.example.com/page",+                "title": "Safari Title",+                "canonicalCandidates": ["https://safari.example.com/canonical"]+            ] as [String: Any]+        ]+        // One provider has URL, another has property list+        let urlProvider = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://provider.example.com")!)+        let plistProvider = FakeItemProvider(hasPropertyList: true, propertyListResult: jsResults)+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [urlProvider, plistProvider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])++        // URL comes from first provider+        #expect(payload.providerURL == "https://provider.example.com")+        // Safari metadata comes independently from property list+        #expect(payload.safari?.locationHref == "https://safari.example.com/page")+        #expect(payload.safari?.title == "Safari Title")+    }++    @Test("Safari preprocessing URL is usable without a separate URL provider")+    func safariPreprocessingURLWithoutProvider() async throws {+        let jsResults: [String: Any] = [+            safariPreprocessingResultsKey: [+                "locationHref": "https://safari.example.com/page",+                "title": "Safari Title",+                "canonicalCandidates": []+            ] as [String: Any]+        ]+        let provider = FakeItemProvider(hasPropertyList: true, propertyListResult: jsResults)+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [provider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item])++        #expect(payload.providerURL == "https://safari.example.com/page")+        #expect(payload.safari?.locationHref == "https://safari.example.com/page")+        #expect(provider.loadURLCallCount == 0)+    }++    @Test("No usable URL throws noUsableURL error")+    func noUsableURLThrows() async throws {+        let provider = FakeItemProvider(hasURL: true, urlError: URLError(.badURL))+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [provider])++        let extractor = SharePayloadExtractor()+        do {+            _ = try await extractor.extract(from: [item])+            Issue.record("Expected SharePayloadExtractionError.noUsableURL")+        } catch SharePayloadExtractionError.noUsableURL {+            // Expected+        } catch {+            Issue.record("Unexpected error: \(error)")+        }+    }++    @Test("Cancelling extraction cancels the outstanding provider Progress and resumes the task")+    func cancellationCancelsOutstandingProgress() async throws {+        let provider = HangingItemProvider()+        let item = FakeExtensionItem(attributedTitleString: nil, providers: [provider])+        let extractor = SharePayloadExtractor()+        let extraction = Task {+            try await extractor.extract(from: [item])+        }++        for _ in 0..<100 {+            if provider.hasStarted { break }+            try await Task.sleep(for: .milliseconds(1))+        }+        #expect(provider.hasStarted)+        extraction.cancel()++        await #expect(throws: CancellationError.self) {+            _ = try await extraction.value+        }+        #expect(provider.wasCancelled)+    }++    @Test("Failed URL provider in first item continues to second item")+    func failedURLContinuesAcrossItems() async throws {+        let failingProvider = FakeItemProvider(hasURL: true, urlError: URLError(.unknown))+        let goodProvider = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://good.example.com")!)+        let item1 = FakeExtensionItem(attributedTitleString: nil, providers: [failingProvider])+        let item2 = FakeExtensionItem(attributedTitleString: nil, providers: [goodProvider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item1, item2])+        #expect(payload.providerURL == "https://good.example.com")+    }++    @Test("Host title from first item preserved when URL comes from second item")+    func hostTitleFromFirstItemWithURLFromSecond() async throws {+        let noURLProvider = FakeItemProvider(hasURL: false)+        let urlProvider = FakeItemProvider(hasURL: true, urlResult: URL(string: "https://example.com")!)+        let item1 = FakeExtensionItem(attributedTitleString: "First Title", providers: [noURLProvider])+        let item2 = FakeExtensionItem(attributedTitleString: "Second Title", providers: [urlProvider])++        let extractor = SharePayloadExtractor()+        let payload = try await extractor.extract(from: [item1, item2])+        // Host title should be from first item (source order)+        #expect(payload.hostTitle == "First Title")+        #expect(payload.providerURL == "https://example.com")+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift Added +425 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swiftnew file mode 100644index 0000000..725345c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift@@ -0,0 +1,425 @@+import Foundation+import Testing+@testable import AsterismCore++// MARK: - Task 12: Share transport and Safari preprocessing tests++@Suite("Share transport and Safari preprocessing", .serialized)+struct ShareTransportTests {++    // MARK: - Provider ordering and URL selection++    @Test("Provider URL is used as raw URL when Safari metadata is absent")+    func providerURLUsedWhenNoSafari() async throws {+        let fixture = try await ShareTransportFixture()+        let payload = SharePayload(providerURL: "https://example.com/chapter-5", hostTitle: "Chapter 5")+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.rawURL == "https://example.com/chapter-5")+    }++    @Test("Safari location.href overrides provider URL as raw URL")+    func safariLocationHrefOverridesProviderURL() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Page Title",+            locationHref: "https://example.com/safari-href"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/provider-url",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.rawURL == "https://example.com/safari-href")+    }++    // MARK: - URL rejection++    @Test("Non-HTTP(S) provider URL throws CapturePreparationError")+    func nonHTTPProviderURLThrows() async throws {+        let fixture = try await ShareTransportFixture()+        let payload = SharePayload(providerURL: "file:///tmp/notes.txt", hostTitle: "Notes")+        await #expect(throws: CapturePreparationError.self) {+            try await fixture.coordinator.prepare(payload)+        }+    }++    @Test("Non-HTTP(S) Safari location.href falls back to provider URL selection")+    func rejectNonHTTPSafariHref() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "FTP Page",+            locationHref: "ftp://files.example.com/doc"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/valid",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        // When Safari href is invalid, provider URL should be used+        #expect(result.rawURL == "https://example.com/valid")+    }++    // MARK: - Title precedence and evidence independence++    @Test("Host title takes precedence over Safari document title")+    func hostTitlePrecedence() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Safari Document Title",+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: "Host Title",+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == "Host Title")+        #expect(result.titleSource == .host)+    }++    @Test("Safari document title used when host title is absent")+    func safariTitleUsedWhenNoHostTitle() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Safari Title",+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == "Safari Title")+        #expect(result.titleSource == .safariDocument)+    }++    @Test("Blank host title is treated as absent — Safari title used")+    func blankHostTitleFallsToSafari() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Safari Title",+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: "   ",+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == "Safari Title")+        #expect(result.titleSource == .safariDocument)+    }++    @Test("Host title and Safari evidence are independent — Safari URL used with host title")+    func hostTitleSafariURLIndependence() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Should Not Be Used",+            locationHref: "https://example.com/safari-url",+            canonicalCandidates: ["https://example.com/canonical"]+        )+        let payload = SharePayload(+            providerURL: "https://example.com/provider",+            hostTitle: "Host Title",+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        // Title comes from host, but URL and canonicals come from Safari+        #expect(result.resolvedTitle == "Host Title")+        #expect(result.titleSource == .host)+        #expect(result.rawURL == "https://example.com/safari-url")+        #expect(result.canonicalURL == "https://example.com/canonical")+    }++    // MARK: - Blank Safari title goes directly to manual without network++    @Test("Blank Safari title goes directly to manual entry without network fetch")+    func blankSafariTitleGoesManualNoNetwork() async throws {+        let fetcher = RecordingPageTitleFetcher()+        let fixture = try await ShareTransportFixture(fetcher: fetcher)+        let safari = SafariPageMetadata(+            title: "",+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == nil)+        #expect(result.titleSource == .manual)+        #expect(fetcher.fetchCount == 0, "Should not call network fetcher when Safari is present")+    }++    @Test("Nil Safari title goes directly to manual entry without network fetch")+    func nilSafariTitleGoesManualNoNetwork() async throws {+        let fetcher = RecordingPageTitleFetcher()+        let fixture = try await ShareTransportFixture(fetcher: fetcher)+        let safari = SafariPageMetadata(+            title: nil,+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == nil)+        #expect(result.titleSource == .manual)+        #expect(fetcher.fetchCount == 0, "Should not call network fetcher when Safari is present")+    }++    // MARK: - Canonical candidates in document order++    @Test("All canonical candidates validated in document order, first valid wins")+    func canonicalCandidatesDocumentOrder() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://example.com/page",+            canonicalCandidates: [+                "",                               // empty — skipped+                "ftp://invalid.example/no-http",  // non-HTTP — skipped+                "https://example.com/canonical-winner",+                "https://example.com/not-reached",+            ]+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.canonicalURL == "https://example.com/canonical-winner")+    }++    @Test("Relative canonical resolved against Safari location.href")+    func relativeCanonicalResolution() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://example.com/dir/page",+            canonicalCandidates: ["/canonical-path"]+        )+        let payload = SharePayload(+            providerURL: "https://example.com/provider",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.canonicalURL == "https://example.com/canonical-path")+    }++    @Test("No valid canonical candidates results in nil canonical")+    func noValidCanonicalsReturnsNil() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://example.com/page",+            canonicalCandidates: ["", "ftp://x.com/not-http", "data:text/html,hello"]+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.canonicalURL == nil)+    }++    @Test("Fragment retained in canonical URL")+    func canonicalFragmentRetained() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://example.com/page",+            canonicalCandidates: ["https://example.com/canonical#section"]+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.canonicalURL == "https://example.com/canonical#section")+    }++    // MARK: - Site status request++    @Test("First-site status reported for new hostname during preparation")+    func firstSiteStatusNewHostname() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://new-site.example/page"+        )+        let payload = SharePayload(+            providerURL: "https://new-site.example/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.siteStatus == .newSite)+    }++    @Test("Existing untaught site status reported when Site exists")+    func existingSiteStatus() async throws {+        let fixture = try await ShareTransportFixture()+        // First capture creates the Site+        _ = try await fixture.repository.capture(CaptureDraft(+            captureTitle: "Seed",+            captureTitleSource: .manual,+            rawURLString: "https://existing.example/first"+        ))++        let safari = SafariPageMetadata(+            title: "Title",+            locationHref: "https://existing.example/second"+        )+        let payload = SharePayload(+            providerURL: "https://existing.example/second",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.siteStatus == .existingUntaught)+    }++    // MARK: - Non-Safari fetcher invocation++    @Test("Network fetcher invoked only when Safari preprocessing is unavailable")+    func fetcherInvokedWithoutSafari() async throws {+        let fetcher = RecordingPageTitleFetcher()+        fetcher.result = FetchedPageMetadata(title: "Fetched Title", canonicalCandidates: [])+        let fixture = try await ShareTransportFixture(fetcher: fetcher)+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: nil+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(fetcher.fetchCount == 1)+        #expect(result.resolvedTitle == "Fetched Title")+        #expect(result.titleSource == .networkFetch)+    }++    @Test("Network fetch failure falls through to manual title")+    func fetcherFailureFallsToManual() async throws {+        let fetcher = RecordingPageTitleFetcher()+        fetcher.shouldFail = true+        let fixture = try await ShareTransportFixture(fetcher: fetcher)+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: nil+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(fetcher.fetchCount == 1)+        #expect(result.resolvedTitle == nil)+        #expect(result.titleSource == .manual)+    }++    @Test("Fetcher not invoked when host title already available")+    func fetcherNotInvokedWithHostTitle() async throws {+        let fetcher = RecordingPageTitleFetcher()+        let fixture = try await ShareTransportFixture(fetcher: fetcher)+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: "Host Title",+            safari: nil+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(fetcher.fetchCount == 0)+        #expect(result.resolvedTitle == "Host Title")+        #expect(result.titleSource == .host)+    }++    // MARK: - Preparation error surfacing++    @Test("Non-HTTP URL throws CapturePreparationError with user message")+    func nonHTTPURLThrowsPreparationError() async throws {+        let fixture = try await ShareTransportFixture()+        let payload = SharePayload(providerURL: "ftp://files.example.com/doc")+        do {+            _ = try await fixture.coordinator.prepare(payload)+            Issue.record("Expected CapturePreparationError to be thrown")+        } catch let error as CapturePreparationError {+            #expect(error.userMessage.contains("URL"))+        } catch {+            Issue.record("Expected CapturePreparationError, got \(type(of: error))")+        }+    }++    @Test("Host title preserved verbatim including leading/trailing whitespace")+    func hostTitlePreservedVerbatim() async throws {+        let fixture = try await ShareTransportFixture()+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: "  Chapter 5  ",+            safari: nil+        )+        let result = try await fixture.coordinator.prepare(payload)+        // Verbatim preservation — trim only for validation+        #expect(result.resolvedTitle == "  Chapter 5  ")+    }++    @Test("Safari title preserved verbatim")+    func safariTitlePreservedVerbatim() async throws {+        let fixture = try await ShareTransportFixture()+        let safari = SafariPageMetadata(+            title: "  Safari Title With Spaces  ",+            locationHref: "https://example.com/page"+        )+        let payload = SharePayload(+            providerURL: "https://example.com/page",+            hostTitle: nil,+            safari: safari+        )+        let result = try await fixture.coordinator.prepare(payload)+        #expect(result.resolvedTitle == "  Safari Title With Spaces  ")+    }+}++// MARK: - Test Infrastructure++private struct ShareTransportFixture {+    let directory: URL+    let configuration: LibraryConfiguration+    let repository: LibraryRepository+    let coordinator: CaptureCoordinator++    init(fetcher: (any PageTitleFetching)? = nil) async throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismShareTransportTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+        repository = try await LibraryRepository.open(+            configuration,+            clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))+        )+        coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)+    }+}++private final class RecordingPageTitleFetcher: PageTitleFetching, @unchecked Sendable {+    private let lock = NSLock()+    private var _fetchCount = 0+    var result: FetchedPageMetadata? = nil+    var shouldFail = false++    var fetchCount: Int {+        lock.withLock { _fetchCount }+    }++    func fetch(url: String) async throws -> FetchedPageMetadata? {+        lock.withLock { _fetchCount += 1 }+        if shouldFail { throw URLError(.timedOut) }+        return result+    }+}
specs/bugfixes/safari-share-url-not-provided/report.md Added +83 / -0
diff --git a/specs/bugfixes/safari-share-url-not-provided/report.md b/specs/bugfixes/safari-share-url-not-provided/report.mdnew file mode 100644index 0000000..7f8ff75--- /dev/null+++ b/specs/bugfixes/safari-share-url-not-provided/report.md@@ -0,0 +1,83 @@+# Bugfix Report: Safari Share URL Not Provided++**Date:** 2026-07-18+**Status:** Fixed++## Description of the Issue++Sharing a webpage from Safari on an iPhone opens the Asterism share extension, but the extension reports “A page URL is required.”++**Reproduction steps:**+1. Install a signed Development or Personal build on an iPhone.+2. Open an HTTP or HTTPS webpage in Safari and choose Asterism from the share sheet.+3. Observe that capture stops with the missing-URL message.++**Impact:** Safari capture—the primary extension flow—cannot save a webpage when Safari supplies only its documented JavaScript preprocessing property list.++## Investigation Summary++- **Symptoms examined:** The user-facing missing-URL branch in `ShareViewController` and the adapter’s `noUsableURL` error.+- **Code inspected:** `Preprocessing.js`, extension `Info.plist`, `ShareInputAdapter`, `SharePayloadExtractor`, `CaptureCoordinator`, and focused transport tests.+- **Hypotheses tested:** Preprocessing configuration and target wiring are present; the defect is reproduced by a Safari property-list attachment with no separate URL representation.++## Discovered Root Cause++`SharePayloadExtractor` decodes Safari’s preprocessing dictionary, including `location.href`, but then unconditionally requires a separately loaded `UTType.url` value. Apple’s documented webpage-access path guarantees the preprocessing results through a property-list item; it does not require an additional URL representation. The valid Safari URL is therefore discarded and extraction throws `noUsableURL`.++**Defect type:** Integration/data-flow error.++**Why it occurred:** Existing tests model Safari metadata only alongside a successful URL provider, so they never exercise the actual property-list-only payload shape.++**Contributing factors:** `SharePayload.providerURL` is nonoptional even though Safari `location.href` is the authoritative raw URL under the feature contract.++## Resolution for the Issue++**Changes made:**+- `Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift:116` - Uses decoded Safari `location.href` when no separate provider URL loaded, allowing the authoritative Safari evidence to satisfy the payload’s nonoptional fallback field.+- `Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift:183` - Covers a Safari preprocessing property list with no URL provider.++**Approach rationale:** This is the smallest change that matches requirements 2.3 and 2.4: Safari `location.href` remains authoritative, while a real provider URL still remains the fallback for non-Safari shares.++**Alternatives considered:**+- Make `SharePayload.providerURL` optional - Semantically cleaner, but unnecessarily expands the public API and every caller for a transport-only compatibility case.+- Change only `NSItemProvider` URL loading - Would not handle Safari’s documented property-list-only webpage payload.++## Regression Test++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift`+**Test name:** `safariPreprocessingURLWithoutProvider`++**What it verifies:** A valid Safari preprocessing result produces a payload even when no attachment advertises or loads a separate URL representation.++**Run command:** `swift test --package-path Packages/AsterismCore --filter safariPreprocessingURLWithoutProvider`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/SharePayloadExtractor.swift` | Accepts Safari `location.href` without a redundant provider URL |+| `Packages/AsterismCore/Tests/AsterismCoreTests/ShareInputAdapterTests.swift` | Adds the Safari-only payload regression |+| `specs/bugfixes/safari-share-url-not-provided/report.md` | Records investigation, resolution, and verification |++## Verification++**Automated:**+- [x] Regression test passes+- [x] All 186 AsterismCore tests pass when the backup lifecycle suite is run separately from the remaining suite; this avoids an unrelated macOS file-protection concurrency issue in the all-at-once package runner while covering every test.+- [x] `make test` passes the complete app unit and UI test suite.+- [x] `make build` passes for the app and embedded extension.+- [x] `git diff --check` passes. No project formatting or lint target/configuration exists, so the repository’s existing Swift style is preserved.++**Manual verification still recommended:**+- Reinstall the corrected build on an iPhone and share an HTTP(S) Safari page to verify the device host payload end to end.++## Prevention++**Recommendations to avoid similar bugs:**+- Model each documented host payload independently instead of assuming all activation representations are delivered together.+- Keep Safari `location.href` authoritative and the provider URL as fallback, matching requirements 2.3 and 2.4.++## Related++- `specs/immutable-capture-safety-net/requirements.md` — requirements 2.1, 2.3, and 2.4+- `specs/immutable-capture-safety-net/design.md` — Share transport and capture state
specs/immutable-capture-safety-net/implementation.md Added +77 / -0
diff --git a/specs/immutable-capture-safety-net/implementation.md b/specs/immutable-capture-safety-net/implementation.mdnew file mode 100644index 0000000..1809327--- /dev/null+++ b/specs/immutable-capture-safety-net/implementation.md@@ -0,0 +1,77 @@+# Immutable Capture & Safety Net — Implementation Explanation++## Beginner Level++### What Changed / What This Does++Asterism now has a real local library instead of the generated sample item screen. A reader can share a web page into Asterism, keep the original URL and best available title, add a note or rating, and save the capture without opening the main app. The app then shows captures in Recent, lets the reader create and edit Works, move captures into a Work or leave them unattached, delete mistaken captures, and export a verified JSON backup.++Development and Personal builds use separate App Groups and stores. This keeps test data and evolving development code away from the reader's daily-use library. If an established store disappears or cannot be opened, Asterism reports the library as unavailable instead of silently creating an empty replacement.++### Why It Matters++The milestone establishes the safety properties needed before real notes accumulate: capture evidence is immutable, compound changes either save completely or not at all, app and extension processes coordinate access to one local store, and every offered backup is checked against the complete source snapshot before sharing.++### Key Concepts++- **App Group:** an Apple-managed shared folder that allows the app and share extension to access the same local library.+- **Snapshot:** an immutable value copied from persistence for UI use, preventing SwiftData model objects from leaking across actors or processes.+- **Fail closed:** if Asterism cannot prove an existing library is safe to open, it stops and reports the problem rather than falling back to an empty store.+- **Canonical backup:** JSON encoded using fixed ordering and formatting rules, so the exact payload bytes can be checksummed and validated reliably.++## Intermediate Level++### Changes Overview++`Packages/AsterismCore` owns the V1 SwiftData schema, URL identity normalization, repository and cross-process lock, capture coordination, bounded metadata fetching, immutable snapshots, and canonical backup codec/exporter. The app target adds snapshot-driven models and the Recent, Works, details, assignment, new-Work, and Settings surfaces. The share extension adds provider/Safari input adaptation, bounded title acquisition, capture UI, and exactly-once completion behavior.++`LibraryRepository` is the only persistence writer. Each operation acquires the appropriate shared or exclusive POSIX lease, creates a fresh `ModelContext`, materializes or mutates the complete operation, and releases the context and lease together. Compound capture, deletion, Work creation, metadata editing, and assignment operations use one save and discard failed contexts.++### Implementation Approach++- The V1 model preserves raw and canonical URL evidence, title and assignment provenance, stable UUIDs, normalized identity keys, millisecond timestamps, and future parsing/rule fields.+- `CaptureCoordinator` separates title/evidence decisions from `NSItemProvider` transport. Safari metadata wins only where specified; non-Safari title fetches are bounded by one three-second deadline and 65,536 decoded bytes.+- `AppLibraryModel` refreshes complete Recent and Works snapshots after activation and successful mutations. Detail models keep editable drafts separate from persisted snapshots.+- Backup export takes one coherent repository snapshot, emits canonical JSON, hashes the exact payload bytes, strictly decodes the result, compares decoded values and inventory to the source, validates references, and only then stages the file for sharing.+- Development and Personal schemes inject distinct app/extension identifiers and App Group entitlements while sharing the same code paths.++### Trade-offs++The repository/snapshot boundary is more explicit than direct `@Query` usage, but it makes atomicity, cross-process refresh, rollback, and backup coherence testable. The custom bounded HTML scanner supports only the metadata needed by capture, trading full-browser parsing for predictable extension memory and time limits. Canonical JSON requires more code than `JSONEncoder`, but makes the V1 checksum contract stable and auditable.++## Expert Level++### Technical Deep Dive++The repository actor serializes in-process access while `flock` leases serialize compound operations across the app and extension. Shared leases protect multi-fetch snapshots; exclusive leases protect bootstrap and mutations. Bootstrap combines an explicit store URL with a durable initialization marker: neither file means first use, both files mean validate and open, a marker without a store fails closed, and a store without a marker is validated before marker recovery. No operation suspends while a lease is held.++Identity normalization lexically rewrites only scheme, host, default port, and specifically listed tracking-query components. It preserves path, fragment, retained query bytes, duplicates, and ordering, avoiding Foundation URL reconstruction of immutable evidence. Site host normalization is deliberately separate from entry identity normalization.++Backup V1 has an exact typed graph and strict parser. Canonical payload bytes are assembled once and embedded unchanged in the envelope. Validation combines SHA-256 integrity, strict syntax/schema checks, typed deep equality, exact source inventory, count and uniqueness checks, relationship resolution, hostname-to-Site invariants, and provenance-to-pattern resolution. This prevents a consistently faulty encoder from validating its own omissions.++The post-review capture bridge now publishes `.saving` synchronously and tracks the active task before starting the repository call. This closes the interval where Save or Cancel could remain enabled while persistence had already been requested. The Works query also predicates unattached entries in SwiftData instead of loading the whole Entry table and filtering in memory.++### Architecture Impact++`AsterismCore` is now the durable boundary shared by all process clients. Future parsing, re-share matching, CloudKit, migration, and restore work must preserve repository-only writes, immutable evidence fields, deterministic ordering, and the V1 backup decoder contract. Optional persisted relationships are already compatible with future CloudKit constraints while repository and validator layers enforce stronger domain invariants.++### Potential Issues++- The app refreshes Recent and Works through separate repository snapshots. Each is coherent, but a cross-process write between those reads can briefly produce different source moments across tabs; current requirements do not demand one combined UI transaction.+- Full-library snapshots and backups intentionally scale with the entire local library. This is appropriate for M1's device-local scope but should be measured as real libraries grow.+- Physical-device signing, App Group provisioning, and share-extension integration still depend on the manual prerequisites in `prerequisites.md`.+- Keyboard auto-focus and all appearance/accessibility combinations should continue to receive device-level verification even though the implementation and automated journeys cover their structural behavior.++## Completeness Assessment++### Fully Implemented++All M1 requirement groups are represented in production code and automated tests: V1 persistence, immutable capture evidence, Safari/provider/network title acquisition, atomic capture, shared local storage, Recent and Works shells, Work editing and assignment, verified full-library backup, configuration isolation, fail-closed bootstrap, and baseline accessibility/presentation behavior.++### Partially Implemented or Requires External Verification++Physical-device signing and App Group provisioning remain prerequisite checks rather than repository-controlled implementation. Device-level verification is still needed for share-extension presentation, keyboard appearance, and provisioning-dependent cross-process behavior.++### Missing++No in-scope implementation gap was identified. Parsing/teaching, identity-based re-share editing, CloudKit, restore/import, search, Work deletion, and final atmospheric polish remain intentionally deferred by the approved requirements and decision log.
specs/immutable-capture-safety-net/tasks.md Modified +31 / -31
diff --git a/specs/immutable-capture-safety-net/tasks.md b/specs/immutable-capture-safety-net/tasks.mdindex 3b2d47a..409242c 100644--- a/specs/immutable-capture-safety-net/tasks.md+++ b/specs/immutable-capture-safety-net/tasks.md@@ -8,14 +8,14 @@ references:  ## Foundation and persistence -- [ ] 1. Scaffold AsterismCore and the share-extension integration points <!-- id:v9e0ib2 -->+- [x] 1. Scaffold AsterismCore and the share-extension integration points <!-- id:v9e0ib2 -->   - Create Packages/AsterismCore with app-extension-safe library, tests, and helper executable.   - Add Personal and Development app/extension configurations with distinct bundle IDs, App Groups, entitlements, store settings, schemes, plist activation, and shared type/protocol shells; do not implement behavior.   - Stream: 1   - Requirements: [1.1](requirements.md#1.1), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [9.1](requirements.md#9.1)   - References: specs/immutable-capture-safety-net/design.md, specs/immutable-capture-safety-net/prerequisites.md -- [ ] 2. Write failing V1 schema and model-invariant tests <!-- id:v9e0ib3 -->+- [x] 2. Write failing V1 schema and model-invariant tests <!-- id:v9e0ib3 -->   - Red: cover enum raw values, defaults, optional relationship storage/inverses, repository nil-to-empty invariants, provenance/rule invariants, millisecond timestamps, persistence round trips, schema V1, and no Item migration.   - Retain each test ModelContainer for the fixture lifetime.   - Blocked-by: v9e0ib2 (Scaffold AsterismCore and the share-extension integration points)@@ -23,63 +23,63 @@ references:   - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [3.7](requirements.md#3.7), [3.9](requirements.md#3.9), [6.6](requirements.md#6.6)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 3. Implement the V1 schema, value objects, snapshots, and clock boundary <!-- id:v9e0ib4 -->+- [x] 3. Implement the V1 schema, value objects, snapshots, and clock boundary <!-- id:v9e0ib4 -->   - Green: add AsterismSchemaV1, empty migration plan, exact fields, CloudKit-compatible optional relationships with inverses, anchor/rule values, Sendable snapshots, nil-to-empty repository helpers, and millisecond clock quantization.   - Blocked-by: v9e0ib3 (Write failing V1 schema and model-invariant tests)   - Stream: 1   - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [3.7](requirements.md#3.7), [3.9](requirements.md#3.9), [6.6](requirements.md#6.6)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 4. Write failing identity and hostname normalization tests <!-- id:v9e0ib5 -->+- [x] 4. Write failing identity and hostname normalization tests <!-- id:v9e0ib5 -->   - Red: add normative vectors and seeded properties for raw-URL-only normalization, numeric default ports, exact tracker removal, protected byte/order preservation, IPv6/userinfo, empty query components, hostname grammar, and terminal dots.   - Blocked-by: v9e0ib4 (Implement the V1 schema, value objects, snapshots, and clock boundary)   - Stream: 1   - Requirements: [1.7](requirements.md#1.7), [2.9](requirements.md#2.9), [6.6](requirements.md#6.6), [7.1](requirements.md#7.1), [7.3](requirements.md#7.3)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 5. Implement entry identity and hostname normalization <!-- id:v9e0ib6 -->+- [x] 5. Implement entry identity and hostname normalization <!-- id:v9e0ib6 -->   - Green: implement lexical EntryIdentityNormalizer and shared hostname normalizers without reconstructing protected URL slices; persist identity version 1.   - Blocked-by: v9e0ib5 (Write failing identity and hostname normalization tests)   - Stream: 1   - Requirements: [1.7](requirements.md#1.7), [2.9](requirements.md#2.9), [6.6](requirements.md#6.6), [7.1](requirements.md#7.1), [7.3](requirements.md#7.3)   - References: specs/immutable-capture-safety-net/design.md, specs/immutable-capture-safety-net/decision_log.md -- [ ] 6. Write failing lock, bootstrap, and fail-closed repository tests <!-- id:v9e0ib7 -->+- [x] 6. Write failing lock, bootstrap, and fail-closed repository tests <!-- id:v9e0ib7 -->   - Red: cover shared/exclusive contention, timeout/cancellation/process-death release, Personal/Development path isolation, helper-process bootstrap races, marker/store states, fetchLimit=1 validation, corrupt stores, coherent reads, and container retention.   - Blocked-by: v9e0ib4 (Implement the V1 schema, value objects, snapshots, and clock boundary)   - 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), [4.5](requirements.md#4.5), [8.2](requirements.md#8.2)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 7. Implement cross-process locking and fail-closed repository bootstrap <!-- id:v9e0ib8 -->+- [x] 7. Implement cross-process locking and fail-closed repository bootstrap <!-- id:v9e0ib8 -->   - Green: implement async POSIX leases, configuration-injected locations, marker-protected opening with fetchLimit=1 probes, private retained ModelContainer, fresh locked contexts, coherent snapshots, configuration isolation, and unavailable/busy errors.   - Blocked-by: v9e0ib7 (Write failing lock, bootstrap, and fail-closed repository tests)   - 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), [4.5](requirements.md#4.5), [8.2](requirements.md#8.2)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 8. Write failing repository tests for capture, Entry curation, and deletion <!-- id:v9e0ib9 -->+- [x] 8. Write failing repository tests for capture, Entry curation, and deletion <!-- id:v9e0ib9 -->   - Red: test atomic Entry+Site capture, first-site versus existing-untaught status, duplicate raw URLs, note/rating timestamps, save rollback by refetch, deterministic Recent grouping, plain Entry deletion, empty-Work retention, and deletion failure rollback.   - Blocked-by: v9e0ib6 (Implement entry identity and hostname normalization), v9e0ib8 (Implement cross-process locking and fail-closed repository bootstrap)   - Stream: 1   - Requirements: [3.3](requirements.md#3.3), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 9. Implement repository capture, Entry curation, and deletion <!-- id:v9e0iba -->+- [x] 9. Implement repository capture, Entry curation, and deletion <!-- id:v9e0iba -->   - Green: implement atomic capture/Site status, deterministic Recent snapshots, note/rating editing, and plain Entry deletion through locked fresh contexts; save each compound operation once.   - Blocked-by: v9e0ib9 (Write failing repository tests for capture, Entry curation, and deletion)   - Stream: 1   - Requirements: [3.3](requirements.md#3.3), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 10. Write failing repository tests for Works, metadata editing, and assignment <!-- id:v9e0ibp -->+- [x] 10. Write failing repository tests for Works, metadata editing, and assignment <!-- id:v9e0ibp -->   - Red: test standalone Work+Site creation, Work defaults and ordering, unattached grouping, same-host destinations, metadata title/type/tag/note edits, manual title provenance, unchanged Entry timestamps, assignment/no-op behavior, and atomic rollback failures.   - Blocked-by: v9e0iba (Implement repository capture, Entry curation, and deletion)   - Stream: 1   - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 11. Implement repository Works, metadata editing, and assignment operations <!-- id:v9e0ibq -->+- [x] 11. Implement repository Works, metadata editing, and assignment operations <!-- id:v9e0ibq -->   - Green: implement deterministic Work snapshots, standalone Work+Site creation, atomic metadata editing, same-host Move to/New Work/Leave unattached, provenance and timestamp rules through locked fresh contexts.   - Blocked-by: v9e0ibp (Write failing repository tests for Works, metadata editing, and assignment)   - Stream: 1@@ -88,42 +88,42 @@ references:  ## Capture extension -- [ ] 12. Write failing share transport and Safari preprocessing tests <!-- id:v9e0ibb -->+- [x] 12. Write failing share transport and Safari preprocessing tests <!-- id:v9e0ibb -->   - Red: cover provider ordering/cancellation, URL rejection, host-title and Safari evidence independence, all canonical candidates in document order, first-site status request, and blank Safari title going directly to manual without network.   - Blocked-by: v9e0ib2 (Scaffold AsterismCore and the share-extension integration points)   - Stream: 2   - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9), [2.10](requirements.md#2.10), [3.3](requirements.md#3.3), [3.12](requirements.md#3.12)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 13. Implement share transport and Safari preprocessing <!-- id:v9e0ibc -->+- [x] 13. Implement share transport and Safari preprocessing <!-- id:v9e0ibc -->   - Green: implement the main-actor ShareInputAdapter, Sendable payloads, Personal/Development extension configuration, preprocessing script/activation rule, candidate validation handoff, Site-status lookup, and blank-Safari manual fallback.   - Blocked-by: v9e0ibb (Write failing share transport and Safari preprocessing tests)   - Stream: 2   - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9), [2.10](requirements.md#2.10), [3.3](requirements.md#3.3), [3.12](requirements.md#3.12)   - References: specs/immutable-capture-safety-net/design.md, specs/immutable-capture-safety-net/decision_log.md -- [ ] 14. Write failing bounded network metadata acquisition tests <!-- id:v9e0ibr -->+- [x] 14. Write failing bounded network metadata acquisition tests <!-- id:v9e0ibr -->   - Red: cover non-Safari-only invocation, redirect-wide deadline, exact 65,536-byte boundary, MIME/status/charset handling, malformed chunked heads, entities, invalid-first canonical links, and manual fallback.   - Blocked-by: v9e0ibc (Implement share transport and Safari preprocessing)   - Stream: 2   - Requirements: [2.2](requirements.md#2.2), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 15. Implement bounded network metadata acquisition <!-- id:v9e0ibs -->+- [x] 15. Implement bounded network metadata acquisition <!-- id:v9e0ibs -->   - Green: implement ephemeral fetcher, charset policy, bounded scanner, ordered canonical validation, strict non-Safari invocation, and manual fallback.   - Blocked-by: v9e0ibr (Write failing bounded network metadata acquisition tests)   - Stream: 2   - Requirements: [2.2](requirements.md#2.2), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 16. Write failing capture state and extension lifecycle tests <!-- id:v9e0ibx -->+- [x] 16. Write failing capture state and extension lifecycle tests <!-- id:v9e0ibx -->   - Red: cover loading/first-site/existing-untaught/manual-title states, focus and ratings, empty note/rating, duplicate-save suppression, draft retention after failure, save-before-completion, and pre-save cancellation.   - Blocked-by: v9e0iba (Implement repository capture, Entry curation, and deletion), v9e0ibs (Implement bounded network metadata acquisition)   - Stream: 2   - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [4.1](requirements.md#4.1), [9.4](requirements.md#9.4), [9.5](requirements.md#9.5)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 17. Implement CaptureCoordinator, capture UI, and extension lifecycle wiring <!-- id:v9e0iby -->+- [x] 17. Implement CaptureCoordinator, capture UI, and extension lifecycle wiring <!-- id:v9e0iby -->   - Green: implement capture state/view, quiet existing-site presentation, accessibility/appearance baseline, ShareViewController, repository save/retry/cancel flow, and exactly-once host completion.   - Blocked-by: v9e0ibx (Write failing capture state and extension lifecycle tests)   - Stream: 2@@ -132,28 +132,28 @@ references:  ## Backup -- [ ] 18. Write failing canonical Backup V1 codec tests <!-- id:v9e0ibf -->+- [x] 18. Write failing canonical Backup V1 codec tests <!-- id:v9e0ibf -->   - Red: add empty/non-empty golden archives and generated properties for exact keys, both provenances, nulls, Unicode, millisecond dates, inverse-ID ordering, strict decoding, canonical re-encode byte equality, SHA-256 tampering, deep equality, and invalid optional-relationship graphs.   - Blocked-by: v9e0ib4 (Implement the V1 schema, value objects, snapshots, and clock boundary)   - Stream: 3   - Requirements: [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [8.7](requirements.md#8.7)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 19. Implement the canonical Backup V1 codec and validator <!-- id:v9e0ibg -->+- [x] 19. Implement the canonical Backup V1 codec and validator <!-- id:v9e0ibg -->   - Green: implement typed records, strict canonical writer/reader with payload range, metadata injection, SHA-256 envelope, invariants, runtime deep equality/inventory/reference checks, and golden fixtures; canonical re-encode equality remains test-only.   - Blocked-by: v9e0ibf (Write failing canonical Backup V1 codec tests)   - Stream: 3   - Requirements: [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [8.7](requirements.md#8.7)   - References: specs/immutable-capture-safety-net/design.md, specs/immutable-capture-safety-net/decision_log.md -- [ ] 20. Write failing backup snapshot and file-lifecycle integration tests <!-- id:v9e0ibh -->+- [x] 20. Write failing backup snapshot and file-lifecycle integration tests <!-- id:v9e0ibh -->   - Red: verify locked point-in-time materialization during concurrent writes, full inventory, no file on failure, exact validated bytes, completeUnlessOpen staging, cancellation cleanup, 24-hour scavenging, and no library mutation.   - Blocked-by: v9e0ib8 (Implement cross-process locking and fail-closed repository bootstrap), v9e0ibg (Implement the canonical Backup V1 codec and validator)   - Stream: 3   - Requirements: [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [8.7](requirements.md#8.7), [8.8](requirements.md#8.8)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 21. Implement backup snapshot export and share lifecycle services <!-- id:v9e0ibi -->+- [x] 21. Implement backup snapshot export and share lifecycle services <!-- id:v9e0ibi -->   - Green: implement repository backup snapshots, BackupExporter, completeUnlessOpen staging, scavenging, diagnostic categories, and system-share payload; do not add restore/import.   - Blocked-by: v9e0ibh (Write failing backup snapshot and file-lifecycle integration tests)   - Stream: 3@@ -162,56 +162,56 @@ references:  ## App surfaces -- [ ] 22. Write failing app runtime and snapshot view-model tests <!-- id:v9e0ibz -->+- [x] 22. Write failing app runtime and snapshot view-model tests <!-- id:v9e0ibz -->   - Red: cover loading/ready/unavailable/retry, configuration isolation, activation refresh, complete snapshot replacement, Recent/Works ordering, Entry/Work drafts, delete/edit submission suppression, failures without optimistic mutation, and cross-view refresh.   - Blocked-by: v9e0ibq (Implement repository Works, metadata editing, and assignment operations)   - Stream: 4   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 23. Implement app runtime and snapshot view models <!-- id:v9e0ic0 -->+- [x] 23. Implement app runtime and snapshot view models <!-- id:v9e0ic0 -->   - Green: implement AppLibraryModel plus Entry/Work/form models with configuration-aware bootstrap, activation reload, drafts, explicit edit/delete commits, retry/error states, and complete repository snapshots.   - Blocked-by: v9e0ibz (Write failing app runtime and snapshot view-model tests)   - Stream: 4   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 24. Write failing Recent and Entry-detail UI tests <!-- id:v9e0ic1 -->+- [x] 24. Write failing Recent and Entry-detail UI tests <!-- id:v9e0ic1 -->   - Red: automate two-tab entry, raw Recent rows without blanket amber treatment, first-site status, Entry detail update/delete, deletion failure, ordering, and accessibility identifiers.   - Blocked-by: v9e0ic0 (Implement app runtime and snapshot view models)   - Stream: 4   - Requirements: [3.3](requirements.md#3.3), [3.12](requirements.md#3.12), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [9.5](requirements.md#9.5)   - References: specs/immutable-capture-safety-net/design.md, docs/asterism-style-guide.md -- [ ] 25. Implement Recent and Entry-detail surfaces <!-- id:v9e0ic2 -->+- [x] 25. Implement Recent and Entry-detail surfaces <!-- id:v9e0ic2 -->   - Green: implement the Recent tab and Entry detail using approved models, neutral M1 raw rows, explicit Update, plain delete, semantic style primitives, and no deferred Teach/search/markdown behavior.   - Blocked-by: v9e0ic1 (Write failing Recent and Entry-detail UI tests)   - Stream: 4   - Requirements: [3.3](requirements.md#3.3), [3.12](requirements.md#3.12), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6)   - References: specs/immutable-capture-safety-net/design.md, docs/asterism-style-guide.md -- [ ] 26. Write failing Works, Work-detail, and assignment UI tests <!-- id:v9e0ic3 -->+- [x] 26. Write failing Works, Work-detail, and assignment UI tests <!-- id:v9e0ic3 -->   - Red: automate Work/empty/unattached ordering, New Work from empty, same-host Move to/New Work/Leave unattached, editable title/type/tags/notes, metadata failure retention, and accessibility identifiers.   - Blocked-by: v9e0ic0 (Implement app runtime and snapshot view models)   - Stream: 4   - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6), [9.5](requirements.md#9.5)   - References: specs/immutable-capture-safety-net/design.md, docs/asterism-style-guide.md -- [ ] 27. Implement Works, editable Work detail, and assignment surfaces <!-- id:v9e0ic4 -->+- [x] 27. Implement Works, editable Work detail, and assignment surfaces <!-- id:v9e0ic4 -->   - Green: implement Works/empty/unattached presentations, New Work, explicit Work metadata Save, and same-host assignment flows using semantic style primitives.   - Blocked-by: v9e0ic3 (Write failing Works, Work-detail, and assignment UI tests)   - Stream: 4   - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6)   - References: specs/immutable-capture-safety-net/design.md, docs/asterism-style-guide.md -- [ ] 28. Write failing Settings backup UI tests <!-- id:v9e0ic5 -->+- [x] 28. Write failing Settings backup UI tests <!-- id:v9e0ic5 -->   - Red: automate Settings entry, backup progress/failure, validated file share presentation, cancellation cleanup, and accessibility identifiers without adding restore or markdown export.   - Blocked-by: v9e0ibi (Implement backup snapshot export and share lifecycle services), v9e0ic0 (Implement app runtime and snapshot view models)   - Stream: 4   - Requirements: [8.1](requirements.md#8.1), [8.5](requirements.md#8.5), [8.8](requirements.md#8.8), [9.5](requirements.md#9.5)   - References: specs/immutable-capture-safety-net/design.md -- [ ] 29. Implement Settings backup and system-share surface <!-- id:v9e0ic6 -->+- [x] 29. Implement Settings backup and system-share surface <!-- id:v9e0ic6 -->   - Green: implement Settings backup progress/error state and system share presentation using completeUnlessOpen staged files; omit restore and markdown export.   - Blocked-by: v9e0ic5 (Write failing Settings backup UI tests)   - Stream: 4@@ -220,7 +220,7 @@ references:  ## Integration -- [ ] 30. Write failing cross-target integration and accessibility tests <!-- id:v9e0ic7 -->+- [x] 30. Write failing cross-target integration and accessibility tests <!-- id:v9e0ic7 -->   - Red: exercise configuration-isolated extension capture then app activation, assignment seen by a fresh extension repository, fail-closed restart, complete real-record backup, all M1 screens, paired appearances, Reduce Transparency, Dynamic Type, labels/hit targets, and simulator UI journeys.   - Physical-device cases depend on prerequisites.md.   - Blocked-by: v9e0iby (Implement CaptureCoordinator, capture UI, and extension lifecycle wiring), v9e0ibi (Implement backup snapshot export and share lifecycle services), v9e0ic2 (Implement Recent and Entry-detail surfaces), v9e0ic4 (Implement Works, editable Work detail, and assignment surfaces), v9e0ic6 (Implement Settings backup and system-share surface)@@ -228,7 +228,7 @@ references:   - Requirements: [1.8](requirements.md#1.8), [2.10](requirements.md#2.10), [3.6](requirements.md#3.6), [3.10](requirements.md#3.10), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [5.1](requirements.md#5.1), [5.6](requirements.md#5.6), [6.3](requirements.md#6.3), [6.7](requirements.md#6.7), [7.3](requirements.md#7.3), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.4](requirements.md#8.4), [8.7](requirements.md#8.7), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6)   - References: specs/immutable-capture-safety-net/design.md, specs/immutable-capture-safety-net/prerequisites.md, docs/asterism-style-guide.md -- [ ] 31. Complete app-extension wiring and make the integration suite pass <!-- id:v9e0ic8 -->+- [x] 31. Complete app-extension wiring and make the integration suite pass <!-- id:v9e0ic8 -->   - Green: finish matching Personal/Development target membership, dependency injection, App Group paths, activation refresh, extension completion, backup sharing, and appearance/accessibility fixes exposed by integration tests.   - Run package, app, UI, and build validation without implementing any non-goal.   - Blocked-by: v9e0ic7 (Write failing cross-target integration and accessibility tests)

Things to double-check

Cross-tab snapshot coherence.

Recent and Works are read through separate repository snapshots. Each is internally coherent, but a cross-process write between the two reads can briefly surface different source moments across tabs. Requirements do not demand one combined UI transaction, so this is accepted for M1 — worth a note for future work.

Device-level verification of the share extension.

App Group provisioning, keyboard auto-focus, and appearance/accessibility combinations still need on-device verification per prerequisites.md; automated coverage exercises the structural behaviour only.

Full-library snapshot scaling.

Backups and UI snapshots intentionally scale with the entire local library. Appropriate for M1's device-local scope, but should be measured as real libraries grow.