Fifteen unpushed commits implementing specs/configuration-identity/: one identity token per build configuration, both identifiers derived from it, LibraryEnvironment deleted, and two verification layers that make divergence fail loudly. Four parallel review agents (reuse, quality, efficiency, spec adherence) raised no blockers; every accepted finding was fixed and re-verified in ad1b3a8/7de5450.
ASTERISM_IDENTITY is declared once per configuration at project level; App Group and CloudKit container identifiers, entitlements, and each product's Info.plist key all derive from it. Resolved values are unchanged.LibraryEnvironment enum and its #if DEBUG selection are gone (~80 call sites). The package takes identifiers by injection; the only bundle read is one reader called at app launch and extension bootstrap.make verify-identity (lint, a test-core prerequisite, no build needed) and an in-build phase on both targets checking the processed .xcent against values recomputed from the token — in both configurations, including Personal which never runs tests..xcent, plutil keypaths split on the dots entitlement keys are full of, and grep -w treats . as a boundary. Each is recorded as a quick decision (Q23–Q25).git grep failures), extended Non-Goal 3 coverage to icloud-services, and cut lint process spawns 209 → 122.Ready to push
All sixteen acceptance criteria verified satisfied by the spec-adherence agent; every check demonstrated to fire (eight-break table plus eight review-round negative tests). The review raised only minor findings — three lint evasion routes, a spec-coverage gap on the extension's iCloud entitlements, stale metadata and docs — and all were fixed and re-verified before this verdict. The two skipped findings are documented accepted-by-design trade-offs.
38a7455 T-1982: Single-source the identity declarations 3948f6e T-1982: Failing package tests for the identity reader (red) 0c49eb6 T-1982: Measure processed entitlements per platform (task 1) ddea1c5 T-1982: Package identity API; delete LibraryEnvironment 6057c85 T-1982: Rewire the app and share extension to injected identifiers 6a440ff T-1982: Add the identity lint and put it on the test-core bar b17e960 [merge]: configuration-identity stream 1 0daa0f6 [merge]: configuration-identity stream 2 987a22d T-1982: Check the processed entitlements in-build, on both targets 1b7a345 T-1982: Demonstrate all eight identity checks firing, then run the full bar 0c90ac8 T-1982: Changelog for configuration-identity 844afc7 T-1982: Record implementation-time decisions Q22-Q25; mark the spec Done afc6c0a T-1982: Move the Req 4.4 table to implementation.md so rune can parse tasks.md ad1b3a8 T-1982: Close the identity lint's evasion routes found in pre-push review 7de5450 T-1982: Record Q26-Q27; note the identity lint in CLAUDE.md Asterism installs as two separate apps — a Development build for testing and a Personal build holding the real library. Each keeps its data in its own App Group container (a shared folder the app and its share extension can both reach). Before this change, those container names were written out by hand in several unrelated places — Swift code, build settings, and the entitlements baked into the app's signature — and nothing checked they agreed.
Now each configuration declares its identity exactly once (ASTERISM_IDENTITY in the Xcode project). Everything else derives from that declaration, and two automated checks fail the build or the test run if anything disagrees.
The nightmare scenario was a build signing with one configuration's App Group while talking to the other's cloud container — real-library data flowing somewhere the test build can reach. A single typo could cause that silently. Now that class of mistake stops the build with a message naming exactly what disagrees.
group.$(ASTERISM_IDENTITY) references the token the way a spreadsheet cell references another cell instead of repeating its value.Four parts in dependency order: declarations (token + two derived settings at project level; four entitlements files collapsed to two holding $(...) references; runtime-readable derived keys in each product's Info.plist), the package API (declaredAppGroupIdentifier(fromInfoDictionary:/in:) throwing on missing/empty/unexpanded values, production(appGroupIdentifier:locator:), LibraryEnvironment deleted), app wiring (app traps at launch naming the missing key — Q2's deliberate exception — while the extension fails through its library-unavailable message, because fatalError in an appex kills the share sheet invisibly), and verification (the lint plus the in-build phase).
Decision 1 is load-bearing: the package receives identifiers by injection and never reads a bundle, so make test-core runs on any host. The two verification layers are complementary — the lint reads source fast and compares both configurations in one pass; the build phase reads what will actually be signed, recomputing expectations from the token (Q20) so a derivation typo cannot vouch for itself.
alwaysOutOfDate=1, ~0.1 s/target) because ambient build settings are inputs no declared path can track..xcent files; the unsuffixed one is an empty dict and -Simulated.xcent is what links into __TEXT,__entitlements. Presence-checking the plain file passes vacuously — hence the SDK-conditional ASTERISM_XCENT_SUFFIX and build check 0 asserting suffix↔platform agreement (Q23).team-identifier/get-task-allow, drops the macOS-only aps key), so whole-file comparison fails on a correct build; checks stay key-scoped to the two identity arrays, unscoped by platform (Q22).plutil keypaths split on . — unescaped, every entitlement-key check resolves to nothing and passes vacuously (Q24). grep -w treats . as a boundary, matching the Personal identifier inside the Development one (Q25).The sweep's sentinel exemption is now honoured only on the Makefile line whose count check_banner enforces; SDK/arch-conditional variants of the three identity settings (distinct pbxproj dictionary keys invisible to exact-keypath extraction) are caught by enumerating buildSettings key names at both levels; git grep exit ≥2 now fails instead of reading as no-matches; both layers assert the extension carries neither iCloud entitlement key (Q27).
INFOPLIST_FILE; a relocated plist leaves it checking a stale path (build check 3 still covers the real one).INFOPLIST_EXPAND_BUILD_SETTINGS assertion, and inspection only.Asterism/Asterism.xcodeproj/project.pbxproj
Why it matters. The root of the whole scheme — if this is wrong, everything downstream inherits it. Project-level ownership (not per-target) is what makes Req 1.3's shared-declaration guarantee true.
What to look at. project.pbxproj:595-599 (Development), :662-666 (Personal), plus the two PBXShellScriptBuildPhase objects
Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift
Why it matters. Removes the #if DEBUG identifier selection that could pair one configuration's store with the other's container — Req 2.5's whole point. ~80 call sites across the package, both CLI tools, and tests now pass identifiers explicitly.
What to look at. declaredAppGroupIdentifier(fromInfoDictionary:/in:), production(appGroupIdentifier:locator:)
Asterism/Asterism/ContentView.swift
Why it matters. Req 2.3's behaviour contract: a misconfigured build must be impossible to miss, but an appex fatalError kills the share sheet with no visible diagnostic — so the two processes fail differently on purpose.
scripts/verify-identity.sh
Why it matters. Req 4.3's no-build check: pinned expected tokens, derivation text, reference-not-literal entitlements, shadow and SDK-conditional-variant detection, an error-aware repo-wide literal sweep, and the Makefile banner — each demonstrated to fire.
What to look at. scripts/verify-identity.sh (new, ~450 lines)
scripts/verify-build-identity.sh
Why it matters. Req 4.1/4.2: the only layer that sees what actually gets signed, running in both configurations including Personal, which never runs unit tests. Recomputes expectations from the token so a derivation typo cannot vouch for itself.
What to look at. scripts/verify-build-identity.sh (new); check 0 wiring assertions, key-scoped array equality, extension iCloud-absence checks
scripts/verify-identity.sh
Why it matters. Three ways a wrong identifier could still have slipped through: a copy-pasted sentinel silencing the sweep anywhere in the repo, SDK/arch-conditional setting variants invisible to both the pin and the shadow scan, and git grep errors reading as clean sweeps. Each was proven exploitable against the pre-fix script before fixing.
make test-core run anywhere and confines the single bundle read to app launch and extension bootstrap..xcent beside the real -Simulated one.plutil splits keypaths on . — unescaped entitlement keys resolve to nothing and the checks pass vacuously.. as a separator, so the Personal identifier matches inside the Development one — the exact strict-prefix collision the sweep exists to catch.icloud-services alone would have passed.fatalError in an appex kills the share sheet with no visible diagnostic, while an app-side trap makes a build error unmissable.| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | verify-identity.sh sentinel | The '# identity-lint: allowed' sentinel exempted git-grep hits in any file, while only the Makefile's sentinel count was enforced — a copy-pasted sentinel elsewhere silenced the literal sweep unnoticed (proven against the pre-fix script). | Exemption now honoured only when the hit's path is the Makefile; negative test fires. |
| minor | verify-identity.sh conditional settings | SDK/arch-conditional variants (ASTERISM_IDENTITY[sdk=iphoneos*]) are distinct pbxproj keys invisible to exact-keypath extraction, evading both the token pin and the shadow scan; the build check cannot backstop it because the conditional supplies the effective value it recomputes from. | buildSettings key names are enumerated at project and target level; any conditional variant of the three identity settings fails, with ASTERISM_XCENT_SUFFIX[sdk=*simulator*] out of scope by construction. |
| minor | verify-identity.sh sweep errors | Both git-grep sweeps read from process substitutions whose exit status was never observed — an erroring grep (exit ≥2) yielded zero lines and a green run. | Output and status captured separately; exit ≥2 fails with the git error surfaced (verified with GIT_DIR pointed at a nonexistent repo). |
| minor | Non-Goal 3 coverage | Both layers asserted only the iCloud container key absent from the extension; adding com.apple.developer.icloud-services would have passed every check. | Both scripts now assert both iCloud keys absent from the extension (Q27); lint and build-phase negative tests fire. |
| minor | verify-identity.sh efficiency | ~209 plutil spawns (~1.3 s) per run: every keypath lookup re-parsed the converted JSON, the object list was scanned three times, and identical extractions repeated. | One pre-pass object table reused by the shadow scan and target lookups; sed-based keypath escaping replaced with a bash substitution in both scripts. 209 spawns / 1.12 s → 122 spawns / 0.83 s. |
| minor | tasks.md metadata | Task 4's Blocked-by line carried stray ', failing, package' tokens duplicated by rune write round-trips. | Line trimmed to the single vjmyza3 reference; rune parses the file and reports 8/8 complete. |
| minor | decision log staleness | Q14 and the design's Landing Order still said the Req 4.4 table is recorded in the tasks file after it moved to implementation.md. | Q26 records the relocation and supersedes Q14's location. |
| nit | CLAUDE.md | The 'no linter' paragraph predates make verify-identity joining the test-core bar. | Paragraph now distinguishes code-style linting (none) from the identity lint and points at the spec. |
| minor | extension runtime path | declaredAppGroupIdentifier inside the .appex and its library-unavailable fallback have no automated test; the simulator bundle test covers only the app. | Accepted by design — Req 3.4 claims app-side coverage only; the extension's key is guarded by the lint and build check 1. |
| nit | verify-identity.sh plist paths | The lint hardcodes the two Info.plist paths instead of resolving INFOPLIST_FILE from the pbxproj — a relocated plist would leave it checking a stale file. | Skipped: build check 3 covers the real built plist, so this is a drift hazard, not a hole; noted as a watch item. |
| nit | cross-script duplication | plutil_keypath and the reference-check helpers are near-identical across the two scripts. | Skipped deliberately: a sourced helper would need a fourth declared input in both sandboxed build phases, and the repo has no shell-sourcing precedent. Worth revisiting if a third identity script appears. |
| nit | AsterismTests container key literal | The app test hardcodes 'AsterismCloudKitContainerIdentifier' and re-implements the reader's validation ladder for it. | Skipped: the package deliberately ships no container reader yet (Non-Goal 1); the keyed generalization belongs to the mirroring work. |
| nit | AppLibraryModel tri-state | Three mutually exclusive optionals across five initializers are a sum type in disguise (pre-existing shape; the diff swaps one parameter 1:1). | Skipped: out of this spec's scope; no new sprawl introduced. |
Click to expand.
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex 23840b2..6da4a77 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -171,6 +171,7 @@ A10000000000000000000007 /* Sources */, A10000000000000000000006 /* Frameworks */, A10000000000000000000008 /* Resources */,+ A10000000000000000000017 /* Verify Configuration Identity */, ); buildRules = ( );@@ -195,6 +196,7 @@ D4A9C79130091593004199A5 /* Frameworks */, D4A9C79230091593004199A5 /* Resources */, A1000000000000000000000D /* Embed App Extensions */,+ A10000000000000000000016 /* Verify Configuration Identity */, ); buildRules = ( );@@ -341,6 +343,53 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */+ A10000000000000000000016 /* Verify Configuration Identity */ = {+ isa = PBXShellScriptBuildPhase;+ alwaysOutOfDate = 1;+ buildActionMask = 2147483647;+ files = (+ );+ inputFileListPaths = (+ );+ inputPaths = (+ "$(TARGET_TEMP_DIR)/$(FULL_PRODUCT_NAME)$(ASTERISM_XCENT_SUFFIX).xcent",+ "$(SRCROOT)/$(INFOPLIST_FILE)",+ "$(SRCROOT)/../scripts/verify-build-identity.sh",+ );+ name = "Verify Configuration Identity";+ outputFileListPaths = (+ );+ outputPaths = (+ );+ runOnlyForDeploymentPostprocessing = 0;+ shellPath = /bin/bash;+ shellScript = "\"$SRCROOT/../scripts/verify-build-identity.sh\"\n";+ };+ A10000000000000000000017 /* Verify Configuration Identity */ = {+ isa = PBXShellScriptBuildPhase;+ alwaysOutOfDate = 1;+ buildActionMask = 2147483647;+ files = (+ );+ inputFileListPaths = (+ );+ inputPaths = (+ "$(TARGET_TEMP_DIR)/$(FULL_PRODUCT_NAME)$(ASTERISM_XCENT_SUFFIX).xcent",+ "$(SRCROOT)/$(INFOPLIST_FILE)",+ "$(SRCROOT)/../scripts/verify-build-identity.sh",+ );+ name = "Verify Configuration Identity";+ outputFileListPaths = (+ );+ outputPaths = (+ );+ runOnlyForDeploymentPostprocessing = 0;+ shellPath = /bin/bash;+ shellScript = "\"$SRCROOT/../scripts/verify-build-identity.sh\"\n";+ };+/* End PBXShellScriptBuildPhase section */+ /* Begin PBXSourcesBuildPhase section */ A10000000000000000000007 /* Sources */ = { isa = PBXSourcesBuildPhase;@@ -395,8 +444,7 @@ 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_ENTITLEMENTS = AsterismShareExtension/AsterismShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V24684SCZN;@@ -424,8 +472,7 @@ 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_ENTITLEMENTS = AsterismShareExtension/AsterismShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V24684SCZN;@@ -453,9 +500,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; 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_ENTITLEMENTS = Asterism/Asterism.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V24684SCZN;@@ -501,9 +546,7 @@ 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_ENTITLEMENTS = Asterism/Asterism.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V24684SCZN;@@ -549,6 +592,11 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;+ ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTITY)";+ ASTERISM_ICLOUD_CONTAINER_IDENTIFIER = "iCloud.$(ASTERISM_IDENTITY)";+ ASTERISM_IDENTITY = me.nore.ig.Asterism.dev;+ ASTERISM_XCENT_SUFFIX = "";+ "ASTERISM_XCENT_SUFFIX[sdk=*simulator*]" = "-Simulated"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";@@ -611,6 +659,11 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;+ ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTITY)";+ ASTERISM_ICLOUD_CONTAINER_IDENTIFIER = "iCloud.$(ASTERISM_IDENTITY)";+ ASTERISM_IDENTITY = me.nore.ig.Asterism;+ ASTERISM_XCENT_SUFFIX = "";+ "ASTERISM_XCENT_SUFFIX[sdk=*simulator*]" = "-Simulated"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
diff --git a/Asterism/Asterism/Asterism.Development.entitlements b/Asterism/Asterism/Asterism.Development.entitlementsdeleted file mode 100644index 3377355..0000000--- a/Asterism/Asterism/Asterism.Development.entitlements+++ /dev/null@@ -1,22 +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>- <string>iCloud.me.nore.ig.Asterism.dev</string>- </array>- <key>com.apple.developer.icloud-services</key>- <array>- <string>CloudKit</string>- </array>- <key>com.apple.security.application-groups</key>- <array>- <string>group.me.nore.ig.Asterism.dev</string>- </array>-</dict>-</plist>
diff --git a/Asterism/Asterism/Asterism.Personal.entitlements b/Asterism/Asterism/Asterism.entitlementssimilarity index 74%rename from Asterism/Asterism/Asterism.Personal.entitlementsrename to Asterism/Asterism/Asterism.entitlementsindex 8354973..87c0aec 100644--- a/Asterism/Asterism/Asterism.Personal.entitlements+++ b/Asterism/Asterism/Asterism.entitlements@@ -4,11 +4,9 @@ <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>- <string>iCloud.me.nore.ig.Asterism</string>+ <string>$(ASTERISM_ICLOUD_CONTAINER_IDENTIFIER)</string> </array> <key>com.apple.developer.icloud-services</key> <array>@@ -16,7 +14,7 @@ </array> <key>com.apple.security.application-groups</key> <array>- <string>group.me.nore.ig.Asterism</string>+ <string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string> </array> </dict> </plist>
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex fba19b7..fa2bf00 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -32,7 +32,7 @@ struct ContentView: View { #if DEBUG || ASTERISM_PERFORMANCE_TESTING switch UITestLaunchSupport.request() { case .disabled:- _model = State(initialValue: AppLibraryModel(environment: .current))+ _model = State(initialValue: AppLibraryModel(appGroupIdentifier: Self.declaredAppGroupIdentifier())) case .seeded(let configuration, let fixture): _model = State( initialValue: AppLibraryModel(@@ -44,10 +44,33 @@ struct ContentView: View { _model = State(initialValue: AppLibraryModel(startupFailureMessage: message)) } #else- _model = State(initialValue: AppLibraryModel(environment: .current))+ _model = State(initialValue: AppLibraryModel(appGroupIdentifier: Self.declaredAppGroupIdentifier())) #endif } + /// The App Group this build was signed for, read from its own bundle.+ ///+ /// Traps rather than falling back (Req 2.3, Q2). The key is derived at build+ /// time from the configuration's one identity declaration and the build-time+ /// check refuses to produce a product without it, so an absent value can only+ /// mean a build defect — and the alternative, guessing an identifier, opens+ /// the wrong library silently. Unreachable when the build checks pass;+ /// unreachable in previews too, because `#Preview` injects an explicit+ /// configuration (Q19).+ private static func declaredAppGroupIdentifier() -> String {+ do {+ return try LibraryConfiguration.declaredAppGroupIdentifier(in: .main)+ } catch {+ fatalError(+ """+ Cannot resolve \(LibraryConfiguration.appGroupInfoPlistKey) from this bundle: \(error)+ The key derives from ASTERISM_IDENTITY in the Xcode project; \+ run `make verify-identity` to find the break in the declaration chain.+ """+ )+ }+ }+ /// Test/Preview initializer — injects an explicit configuration. init(configuration: LibraryConfiguration) { _model = State(initialValue: AppLibraryModel(configuration: configuration))@@ -247,6 +270,14 @@ struct ContentView: View { } } +// An explicit configuration, not the production initializer (Q19): a preview+// agent's `Bundle.main` is not the app bundle and no build check governs it, so+// production resolution here could hit the Req 2.3 trap as a false positive. #Preview {- ContentView()+ ContentView(+ configuration: LibraryConfiguration(+ rootDirectory: FileManager.default.temporaryDirectory+ .appending(path: "asterism-preview", directoryHint: .isDirectory)+ )+ ) }
diff --git a/Asterism/Asterism/Info.plist b/Asterism/Asterism/Info.plistindex c8e9c56..f4cc53c 100644--- a/Asterism/Asterism/Info.plist+++ b/Asterism/Asterism/Info.plist@@ -4,8 +4,8 @@ <dict> <key>AsterismAppGroupIdentifier</key> <string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>- <key>AsterismStoreRelativePath</key>- <string>$(ASTERISM_STORE_RELATIVE_PATH)</string>+ <key>AsterismCloudKitContainerIdentifier</key>+ <string>$(ASTERISM_ICLOUD_CONTAINER_IDENTIFIER)</string> <key>UIBackgroundModes</key> <array> <string>remote-notification</string>
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex aaa2cc6..540b53d 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -141,10 +141,7 @@ enum UITestLaunchSupport { } return .seeded(- configuration: LibraryConfiguration(- rootDirectory: rootDirectory,- environment: .development- ),+ configuration: LibraryConfiguration(rootDirectory: rootDirectory), fixture: fixture ) }
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 30dd23f..353cac9 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -33,8 +33,9 @@ public final class AppLibraryModel { /// 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?+ /// When bundle-declared resolution is used, this holds the declared App Group+ /// identifier the embedding target read from its own bundle (Decision 1).+ private let appGroupIdentifier: String? /// The locator used for App Group resolution (injectable for tests). private let locator: any SharedContainerLocating @@ -49,16 +50,17 @@ public final class AppLibraryModel { /// Cleared after seeding so a later in-process bootstrap cannot seed twice. private var uiTestFixture: UITestFixtureKind? - /// Production initializer: resolves configuration from the compile-time environment at bootstrap.- /// Fails closed (→ unavailable) when App Group resolution fails.+ /// Production initializer: resolves the configuration from the App Group+ /// identifier its bundle declares. Fails closed (→ unavailable) when App+ /// Group resolution fails. public init(- environment: LibraryEnvironment,+ appGroupIdentifier: String, locator: any SharedContainerLocating = SystemSharedContainerLocator(), capabilities: AsterismCapabilities = .current ) { self.capabilities = capabilities self.explicitConfiguration = nil- self.environment = environment+ self.appGroupIdentifier = appGroupIdentifier self.locator = locator self.startupFailureMessage = nil self.uiTestFixture = nil@@ -71,7 +73,7 @@ public final class AppLibraryModel { ) { self.capabilities = capabilities self.explicitConfiguration = configuration- self.environment = nil+ self.appGroupIdentifier = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = nil self.uiTestFixture = nil@@ -85,7 +87,7 @@ public final class AppLibraryModel { ) { self.capabilities = capabilities self.explicitConfiguration = configuration- self.environment = nil+ self.appGroupIdentifier = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = nil self.uiTestFixture = uiTestFixture@@ -106,7 +108,7 @@ public final class AppLibraryModel { ) { self.capabilities = capabilities self.explicitConfiguration = nil- self.environment = nil+ self.appGroupIdentifier = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = nil self.uiTestFixture = nil@@ -121,7 +123,7 @@ public final class AppLibraryModel { ) { self.capabilities = capabilities self.explicitConfiguration = nil- self.environment = nil+ self.appGroupIdentifier = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = startupFailureMessage self.uiTestFixture = nil@@ -141,11 +143,13 @@ public final class AppLibraryModel { if let explicit = explicitConfiguration { configuration = explicit } else {- guard let env = environment else {- state = .unavailable(message: "No library environment configured.")+ guard let appGroupIdentifier else {+ state = .unavailable(message: "No App Group identifier configured.") return }- configuration = try LibraryConfiguration.production(environment: env, locator: locator)+ configuration = try LibraryConfiguration.production(+ appGroupIdentifier: appGroupIdentifier, locator: locator+ ) } resolvedConfiguration = configuration
diff --git a/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlements b/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlementsdeleted file mode 100644index baf648a..0000000--- a/Asterism/AsterismShareExtension/AsterismShareExtension.Development.entitlements+++ /dev/null@@ -1,5 +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>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism.dev</string></array>-</dict></plist>
diff --git a/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlements b/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlementsdeleted file mode 100644index 8fa6706..0000000--- a/Asterism/AsterismShareExtension/AsterismShareExtension.Personal.entitlements+++ /dev/null@@ -1,5 +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>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism</string></array>-</dict></plist>
diff --git a/Asterism/AsterismShareExtension/AsterismShareExtension.entitlements b/Asterism/AsterismShareExtension/AsterismShareExtension.entitlementsnew file mode 100644index 0000000..ee34b29--- /dev/null+++ b/Asterism/AsterismShareExtension/AsterismShareExtension.entitlements@@ -0,0 +1,10 @@+<?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>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>+ </array>+</dict>+</plist>
diff --git a/Asterism/AsterismShareExtension/Info.plist b/Asterism/AsterismShareExtension/Info.plistindex fabaca9..dff47b0 100644--- a/Asterism/AsterismShareExtension/Info.plist+++ b/Asterism/AsterismShareExtension/Info.plist@@ -2,6 +2,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>AsterismAppGroupIdentifier</key>+ <string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string> <key>CFBundleDisplayName</key> <string>Asterism</string> <key>CFBundleExecutable</key>
diff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftindex f29e629..89a9d83 100644--- a/Asterism/AsterismShareExtension/ShareViewController.swift+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -28,10 +28,15 @@ final class ShareViewController: UIViewController { @MainActor private func bootstrap() async {- // 1. Fail-closed configuration resolution.+ // 1. Fail-closed configuration resolution. `Bundle.main` here is the+ // `.appex`, which carries its own derived AsterismAppGroupIdentifier —+ // the same declaration the extension's entitlements were signed against+ // (Req 2.1). A failure renders the message rather than trapping (Q2): a+ // fatalError in an appex kills the share sheet with nothing to read. let configuration: LibraryConfiguration do {- configuration = try LibraryConfiguration.production(environment: .current)+ let appGroupIdentifier = try LibraryConfiguration.declaredAppGroupIdentifier(in: .main)+ configuration = try LibraryConfiguration.production(appGroupIdentifier: appGroupIdentifier) } catch { host(AnyView(MessageCaptureView(message: "Library unavailable. The app group could not be resolved."))) return
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex 4e4c9e8..444f673 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -7,12 +7,15 @@ import Testing /// complete snapshot replacement, and configuration isolation. @Suite("AppLibraryModel") struct AppLibraryModelTests {+ /// A fixture identifier. The real values are declared once in the Xcode+ /// project; nothing in a test may restate one (lint check 5).+ private static let fixtureAppGroup = "group.example.fixture"+ @Test("Starts in loading state") @MainActor func initialState() { let config = LibraryConfiguration(- rootDirectory: URL(filePath: "/tmp/test-\(UUID())"),- environment: .development+ rootDirectory: URL(filePath: "/tmp/test-\(UUID())") ) let model = AppLibraryModel(configuration: config) #expect(model.state == .loading)@@ -27,8 +30,7 @@ struct AppLibraryModelTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-setup-\(UUID())") let config = LibraryConfiguration(- rootDirectory: root,- environment: .development+ rootDirectory: root ) let model = AppLibraryModel(configuration: config) await model.bootstrap()@@ -42,7 +44,7 @@ struct AppLibraryModelTests { @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 config = LibraryConfiguration(rootDirectory: tmp) try? publishV3Ready(for: config) let model = AppLibraryModel(configuration: config) await model.bootstrap()@@ -55,8 +57,7 @@ struct AppLibraryModelTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-retry-\(UUID())") let config = LibraryConfiguration(- rootDirectory: root,- environment: .development+ rootDirectory: root ) let model = AppLibraryModel(configuration: config) await model.bootstrap()@@ -70,7 +71,7 @@ struct AppLibraryModelTests { @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 config = LibraryConfiguration(rootDirectory: tmp) try? publishV3Ready(for: config) let model = AppLibraryModel(configuration: config) await model.bootstrap()@@ -82,26 +83,15 @@ struct AppLibraryModelTests { 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)- }+ // `configurationIsolation` stood here. With identifiers injected it compared two+ // configurations built from two different roots — a tautology once the enum that+ // supplied the roots is gone. The invariant it stood for, Development and Personal+ // never resolving to the same App Group, is `scripts/verify-identity.sh` check 1. @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 config = LibraryConfiguration(rootDirectory: tmp) try? publishV3Ready(for: config) let model = AppLibraryModel(configuration: config) await model.bootstrap()@@ -123,7 +113,7 @@ struct AppLibraryModelTests { @Test("Locator failure transitions to unavailable without opening any repository") @MainActor func locatorFailureRendersUnavailable() async { let locator = FailingLocator()- let model = AppLibraryModel(environment: .development, locator: locator)+ let model = AppLibraryModel(appGroupIdentifier: Self.fixtureAppGroup, locator: locator) await model.bootstrap() guard case .unavailable(let message) = model.state else { Issue.record("Expected unavailable state, got \(model.state)")@@ -136,7 +126,7 @@ struct AppLibraryModelTests { @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)+ let model = AppLibraryModel(appGroupIdentifier: Self.fixtureAppGroup, locator: locator) // Record state of common fallback locations let tmpFallback = URL(filePath: "/tmp/asterism-fallback")@@ -152,14 +142,14 @@ struct AppLibraryModelTests { #expect(FileManager.default.fileExists(atPath: docs.path) == docsExistedBefore) } - @Test("Environment-based model with working locator succeeds")- @MainActor func environmentBasedModelWithWorkingLocator() async {+ @Test("A model resolving through its declared identifier succeeds with a working locator")+ @MainActor func declaredIdentifierModelWithWorkingLocator() async { let tmp = FileManager.default.temporaryDirectory.appending(path: "asterism-locator-\(UUID())") let locator = StubLocator(url: tmp) // Create V3 readiness so bootstrap proceeds to ready- let config = LibraryConfiguration(rootDirectory: tmp, environment: .development)+ let config = LibraryConfiguration(rootDirectory: tmp) try? publishV3Ready(for: config)- let model = AppLibraryModel(environment: .development, locator: locator)+ let model = AppLibraryModel(appGroupIdentifier: Self.fixtureAppGroup, locator: locator) await model.bootstrap() #expect(model.state == .ready) try? FileManager.default.removeItem(at: tmp)
diff --git a/Asterism/AsterismTests/AsterismTests.swift b/Asterism/AsterismTests/AsterismTests.swiftindex 56493ed..0c952d0 100644--- a/Asterism/AsterismTests/AsterismTests.swift+++ b/Asterism/AsterismTests/AsterismTests.swift@@ -6,11 +6,39 @@ // import AsterismCore+import Foundation import Testing struct AsterismTests { @Test("App links the core module") func linksCoreModule() {- #expect(LibraryEnvironment.allCases.count == 2)+ #expect(LibraryConfiguration.appGroupInfoPlistKey == "AsterismAppGroupIdentifier")+ }++ /// Req 2.4's only end-to-end proof, and the only proof that the app target's+ /// `GENERATE_INFOPLIST_FILE = YES` merge with an explicit `INFOPLIST_FILE`+ /// preserves custom keys (Q21).+ ///+ /// This bundle is hosted in the app, so `Bundle.main` is the built+ /// Development app — the same bundle a launch reads. Asserted by shape rather+ /// than by value: the composed identifiers are declared once in the Xcode+ /// project, and restating one here is exactly what the lint's literal sweep+ /// refuses.+ @Test("The hosted app's bundle carries both derived identity keys")+ func bundleCarriesDerivedIdentityKeys() throws {+ let appGroup = try LibraryConfiguration.declaredAppGroupIdentifier(in: .main)+ #expect(appGroup.hasPrefix("group."))+ #expect(appGroup.hasSuffix(".dev"), "The hosted app is the Development configuration")++ let container = Bundle.main.object(forInfoDictionaryKey: "AsterismCloudKitContainerIdentifier") as? String+ let resolvedContainer = try #require(container, "AsterismCloudKitContainerIdentifier is missing")+ #expect(!resolvedContainer.isEmpty)+ #expect(!resolvedContainer.contains("$("), "The build setting was not expanded")+ #expect(resolvedContainer.hasPrefix("iCloud."))++ // Both derive from the one ASTERISM_IDENTITY declaration, so they share+ // their stem. A pairing of one configuration's App Group with the other's+ // container — the failure this spec exists to prevent — breaks this.+ #expect(resolvedContainer.dropFirst("iCloud.".count) == appGroup.dropFirst("group.".count)) } }
diff --git a/Asterism/AsterismTests/CrossViewRefreshTests.swift b/Asterism/AsterismTests/CrossViewRefreshTests.swiftindex ec29d7a..0286f5f 100644--- a/Asterism/AsterismTests/CrossViewRefreshTests.swift+++ b/Asterism/AsterismTests/CrossViewRefreshTests.swift@@ -29,7 +29,7 @@ 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 config = LibraryConfiguration(rootDirectory: tmp) publishV3ReadyForTests(for: config) let appModel = AppLibraryModel(configuration: config) await appModel.bootstrap()@@ -47,7 +47,7 @@ struct CrossViewRefreshTests { @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 config = LibraryConfiguration(rootDirectory: tmp) publishV3ReadyForTests(for: config) let appModel = AppLibraryModel(configuration: config) await appModel.bootstrap()@@ -76,7 +76,7 @@ struct CrossViewRefreshTests { @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 config = LibraryConfiguration(rootDirectory: tmp) publishV3ReadyForTests(for: config) let appModel = AppLibraryModel(configuration: config) await appModel.bootstrap()@@ -110,7 +110,7 @@ struct CrossViewRefreshTests { @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 config = LibraryConfiguration(rootDirectory: tmp) publishV3ReadyForTests(for: config) let appModel = AppLibraryModel(configuration: config) await appModel.bootstrap()
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex d077c3c..4d69586 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -166,7 +166,10 @@ struct IntegrationSafetyNetTests { let fixture = try IntegrationFixture() defer { fixture.cleanup() } - for environment in [LibraryEnvironment.development, .personal] {+ // Plain labels, not enum cases: `LibraryEnvironment` is gone. Only the+ // element type changes — the loop still gives each iteration its own root,+ // its own capture URL, and its own diagnostic text.+ for environment in ["development", "personal"] { for capabilities in [ AsterismCapabilities.m2_0, .m2_1,@@ -175,10 +178,9 @@ struct IntegrationSafetyNetTests { ] { let configuration = LibraryConfiguration( rootDirectory: fixture.baseDirectory.appending(- path: "\(environment.rawValue)-\(capabilities.gate.rawValue)",+ path: "\(environment)-\(capabilities.gate.rawValue)", directoryHint: .isDirectory- ),- environment: environment+ ) ) try publishV3Ready(for: configuration) let app = AppLibraryModel(@@ -198,7 +200,7 @@ struct IntegrationSafetyNetTests { CaptureDraft( captureTitle: "Work :: Chapter", captureTitleSource: .manual,- rawURLString: "https://\(capabilities.gate.rawValue).\(environment.rawValue).test/1"+ rawURLString: "https://\(capabilities.gate.rawValue).\(environment).test/1" ) ) await app.handleActivation()@@ -209,12 +211,12 @@ struct IntegrationSafetyNetTests { ) guard let backup = app.settingsBackupModel() else {- Issue.record("Expected Backup wiring for \(environment.rawValue) \(capabilities.gate.rawValue)")+ Issue.record("Expected Backup wiring for \(environment) \(capabilities.gate.rawValue)") continue } await backup.startExport() guard let backupURL = backup.exportedFileURL else {- Issue.record("Expected Backup export for \(environment.rawValue) \(capabilities.gate.rawValue)")+ Issue.record("Expected Backup export for \(environment) \(capabilities.gate.rawValue)") continue } let document = try BackupV4Codec.decode(Data(contentsOf: backupURL))@@ -1044,12 +1046,10 @@ private final class IntegrationFixture: @unchecked Sendable { .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+ rootDirectory: baseDirectory.appending(path: "development", directoryHint: .isDirectory) ) personalConfiguration = LibraryConfiguration(- rootDirectory: baseDirectory.appending(path: "personal", directoryHint: .isDirectory),- environment: .personal+ rootDirectory: baseDirectory.appending(path: "personal", directoryHint: .isDirectory) ) if publishReadiness { try Self.publishV3Ready(for: developmentConfiguration)
diff --git a/Asterism/AsterismTests/SettingsImportTests.swift b/Asterism/AsterismTests/SettingsImportTests.swiftindex 139b897..a2e43d0 100644--- a/Asterism/AsterismTests/SettingsImportTests.swift+++ b/Asterism/AsterismTests/SettingsImportTests.swift@@ -94,8 +94,7 @@ struct SettingsBackupImportModelTests { private func makeConfiguration() -> LibraryConfiguration { LibraryConfiguration(- rootDirectory: URL(filePath: "/tmp/test-settings-import-\(UUID())"),- environment: .development+ rootDirectory: URL(filePath: "/tmp/test-settings-import-\(UUID())") ) }
diff --git a/Asterism/AsterismTests/UITestLaunchSupportTests.swift b/Asterism/AsterismTests/UITestLaunchSupportTests.swiftindex e3add2d..7fbd110 100644--- a/Asterism/AsterismTests/UITestLaunchSupportTests.swift+++ b/Asterism/AsterismTests/UITestLaunchSupportTests.swift@@ -34,7 +34,6 @@ struct UITestLaunchSupportTests { return } #expect(fixture == .untaught)- #expect(configuration.environment == .development) #expect(configuration.rootDirectory.path.hasPrefix(temporaryDirectory.path + "/AsterismUITests/")) #expect(configuration.rootDirectory.lastPathComponent == runID.uuidString) }@@ -75,7 +74,6 @@ struct UITestLaunchSupportTests { return } #expect(fixture == .scale)- #expect(configuration.environment == .development) #expect(configuration.rootDirectory.path.contains("/AsterismUITests/")) } @@ -102,7 +100,6 @@ struct UITestLaunchSupportTests { #expect(fixture == .scaleM4) // The M4 fixture is wholly legal by construction, so it needs no reopen. #expect(!fixture.requiresReopenAfterSeeding)- #expect(configuration.environment == .development) #expect(configuration.rootDirectory.path.contains("/AsterismUITests/")) }
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex cda6fde..7bdc730 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Each build configuration now declares its identity once — `ASTERISM_IDENTITY` in the Xcode project — and derives both the App Group and the CloudKit container identifier from it. Entitlements and each product's Info.plist carry references to the derived settings instead of repeating literals, and the resolved values are unchanged: Development keeps its `.dev` pair, Personal keeps the bare pair. The `LibraryEnvironment` enum and its `#if DEBUG` selection are deleted; the app and the share extension read the identifier from their own bundle at launch (the app stops with a diagnostic naming the missing key, the extension fails through its existing library-unavailable message), and the AsterismCore package takes identifiers as explicit input with no bundle reading. Divergence now fails loudly twice over: `make verify-identity` (a new `test-core` prerequisite) checks the declarations without building anything, and an in-build phase on both targets verifies the processed entitlements against values recomputed from the token — in both configurations, including Personal, which never runs unit tests. Every check was demonstrated to fire on a deliberate break before landing. `AsterismMigrationTool` and `make migrate-m1-to-m2` now take an explicit root (`MIGRATION_ROOT`) instead of an `--environment` flag. - An Entry and a Work now carry a modelled relationship to their Site (schema V5) alongside the hostname strings they have always recorded. The relationship is the reference the app will act on; the strings remain capture-time evidence and the archive's reference format, so backups are unaffected. The store converts to V5 the first time the app opens it after this update, and the same launch populates the relationships from the recorded hostnames — on a large library this pass dominates that first launch: five thousand entries measured ~17 s in a host release build, above the 10 s the requirements budgeted, recorded as a known issue while the device number remains unmeasured. The pass itself: every hostname resolves through the same deterministic row selection the app uses everywhere else, records whose hostname matches no Site keep a nil relationship as a tolerated state, and the whole pass commits as one save with the readiness marker published only afterwards — an interrupted launch simply re-runs the pass and converges. The share extension declines to capture into a library until that certification has happened; a fresh, empty library is certified immediately and shares from first launch. After certification, every write the app makes — capture, creating a Work, moving an entry, teaching commits, and backup import — sets the relationship together with the hostname it has always recorded, selecting the Site row the same way the migration does, so the two halves cannot drift apart. - Resolving a rule an Entry cites now looks only among the rules owned by the Site record the Entry itself points at, instead of searching the union of every row sharing the hostname — with each record carrying its own reference, the ambiguity that union existed to bridge is gone. Which Site presents a screen is unchanged: mode, title cleaning, and offered actions still follow the hostname's current teaching, while a record's recorded provenance now survives any change in which row that is. - Recent and an Entry's detail screen now render a row whose cited title pattern cannot be resolved — marked as needing attention, with the cited pattern's identity kept visible as evidence — instead of the one unresolvable citation failing the whole feed or screen. Re-teaching the site remains available on such a row, because re-teaching is the repair.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 04a0aac..0b0ba03 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -50,9 +50,12 @@ The `-m4` and `-m4-recent` targets are easy to confuse and only one of them is s If you run `swift test` by hand instead of through the Makefile, pass `--no-parallel`. It is load-bearing, not a flakiness preference: mixed-schema suites in one parallel process fight over SwiftData's global entity registry and die with `NSUnknownKeyException`. See `docs/agent-notes/testing.md`. -There is no linter or formatter configured in this repo — no `.swiftformat`,-`.swiftlint.yml`, or `.swift-format`, and no `make lint`/`format` target. A-clean `make test-core` with no new compiler warnings is the pre-commit bar.+There is no code-style linter or formatter configured in this repo — no+`.swiftformat`, `.swiftlint.yml`, or `.swift-format`, and no `make lint`/`format`+target. A clean `make test-core` with no new compiler warnings is the pre-commit+bar. `make verify-identity` (a `test-core` prerequisite) is an identity lint,+not a style one: it checks the App Group / CloudKit identifier declarations+without building anything — see `specs/configuration-identity/`. ### Configurations are not interchangeable
diff --git a/Makefile b/Makefileindex fa9efeb..a8e270a 100644--- a/Makefile+++ b/Makefile@@ -55,7 +55,8 @@ help: @echo " archive - Create a Release iOS archive" @echo "" @echo " Utilities:"- @echo " migrate-m1-to-m2 - Explicitly copy V1 to fresh V2 (optional MIGRATION_ROOT)"+ @echo " verify-identity - Check the configuration identity declarations agree"+ @echo " migrate-m1-to-m2 - Explicitly copy V1 to fresh V2 (requires MIGRATION_ROOT)" @echo " resolve - Resolve Swift package dependencies" @echo " devices - List known physical devices" @echo " clean - Remove repository-local build artifacts"@@ -63,21 +64,38 @@ help: @echo "Overrides: SIMULATOR='iPhone 17 Pro', DEVICE_MODEL='iPhone 17 Pro', CONFIG=Release," @echo " PERFORMANCE_LOG=/tmp/perf.log (collects measured p95 values)" +# The build-independent half of the configuration-identity checks: it compares the+# Development and Personal declarations without building anything, which no+# build-time check can do. It is a prerequisite of `test-core` rather than a+# standalone target alone (Q13) -- a clean `make test-core` is the repo's stated+# pre-commit bar, and a guard reachable only through a target nobody runs is a+# guard that is silently broken.+.PHONY: verify-identity+verify-identity:+ @scripts/verify-identity.sh+ .PHONY: test-core # Swift package tests are not members of the app scheme's test plan. # `--no-parallel` is load-bearing, not a speed choice: parallel `swift test` # crashes the run with `NSUnknownKeyException: the entity Site is not key value # coding-compliant for the key "entries"` (Q34, docs/agent-notes/testing.md).-test-core:+test-core: verify-identity $(PIPEFAIL) swift test --package-path Packages/AsterismCore --no-parallel $(if $(CORE_TEST),--filter '$(CORE_TEST)',) $(PIPE_PRETTY) -MIGRATION_ENVIRONMENT ?= development+# MIGRATION_ROOT is required (Q15). The tool used to default to resolving the+# host Mac's App Group container from a compiled-in identifier; that identifier+# now lives in the app's bundle, which this tool cannot read, so the library's+# root is the tool's only honest input. MIGRATION_ROOT ?= .PHONY: migrate-m1-to-m2 migrate-m1-to-m2:+ @if [ -z "$(MIGRATION_ROOT)" ]; then \+ echo "Error: MIGRATION_ROOT is required."; \+ echo "Example: make migrate-m1-to-m2 MIGRATION_ROOT=/path/to/library"; \+ exit 2; \+ fi swift run --package-path Packages/AsterismCore AsterismMigrationTool \- --environment "$(MIGRATION_ENVIRONMENT)" \- $(if $(MIGRATION_ROOT),--root "$(MIGRATION_ROOT)",)+ --root "$(MIGRATION_ROOT)" .PHONY: build build-ios build: build-ios@@ -116,13 +134,23 @@ test-quick: # install it over the real app on a real phone, then drive it. Set # CONFIRM_DEVICE_RUN=1 to skip the prompt in CI. Never set it by default, and # never answer the prompt on the owner's behalf.+#+# The app-group line spells its identifier out rather than expanding a build+# setting, because `make` cannot expand one and the warning must be readable by+# the person deciding whether to let a command touch their phone (Q8).+# `verify-identity` exempts that one line from its literal sweep and separately+# asserts it still matches the Personal declaration, so the warning cannot rot.+# The exemption is keyed on a sentinel that has to sit on the literal's own line;+# the `: "..."` shell no-op is how it gets there. An unquoted `#` would not work:+# make joins this whole define into one recipe line, so the comment would swallow+# the rest of the banner. define device_run_warning @echo ""; \ echo " About to run a UI test suite on a PHYSICAL DEVICE."; \ echo ""; \ echo " device : $(DEVICE_MODEL) ($(DEVICE_ID))"; \ echo " app : me.nore.ig.Asterism (Personal configuration)"; \- echo " app group : group.me.nore.ig.Asterism"; \+ echo " app group : group.me.nore.ig.Asterism"; : "# identity-lint: allowed"; \ echo ""; \ echo " This BUILDS AND INSTALLS over that app on the device, then launches"; \ echo " it repeatedly under a test scenario. The scenario seeds an isolated"; \
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftindex facb859..3a89d9a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -1,31 +1,5 @@ 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/AsterismV2.sqlite"@@ -51,11 +25,9 @@ public struct LibraryConfiguration: Sendable, Equatable { } public let rootDirectory: URL- public let environment: LibraryEnvironment - public init(rootDirectory: URL, environment: LibraryEnvironment) {+ public init(rootDirectory: URL) { self.rootDirectory = rootDirectory- self.environment = environment } public var storeURL: URL {@@ -99,3 +71,61 @@ public struct LibraryConfiguration: Sendable, Equatable { Self.migrationSidecarURL(forStoreAt: v4StoreURL) } }++// MARK: - Declared identity++/// Reads the App Group identifier a product's bundle declares. The value is+/// derived at build time from the configuration's one `ASTERISM_IDENTITY`+/// declaration, so the identifier a process uses and the one its entitlements+/// are signed with cannot disagree.+///+/// The bundle (or its info dictionary) is an explicit parameter: the package+/// reads no ambient `Bundle.main` and sniffs no environment, so host tests+/// exercise exactly the production code path with fixture dictionaries (Q11,+/// Decision 1). There is no reader for the CloudKit container identifier — the+/// build carries the value, and the mirroring work adds consumption when it+/// needs it.+public extension LibraryConfiguration {+ /// The Info.plist key each product's bundle carries.+ static let appGroupInfoPlistKey = "AsterismAppGroupIdentifier"++ /// Resolves the declared App Group identifier, or throws naming the key.+ ///+ /// Never falls back to a default (Req 2.3): a missing, empty, non-string, or+ /// unexpanded value can only mean a build defect, and guessing an identifier+ /// would point the process at the wrong library.+ static func declaredAppGroupIdentifier(fromInfoDictionary dictionary: [String: Any]?) throws -> String {+ func unavailable(_ reason: String) -> LibraryRepositoryError {+ .libraryUnavailable(+ operation: "resolving the declared App Group identifier",+ reason: "\(appGroupInfoPlistKey) \(reason)"+ )+ }++ guard let dictionary else {+ throw unavailable("could not be read: the bundle has no info dictionary")+ }+ guard let value = dictionary[appGroupInfoPlistKey] else {+ throw unavailable("is missing from the bundle's Info.plist")+ }+ guard let identifier = value as? String else {+ throw unavailable("is not a string")+ }+ guard !identifier.isEmpty else {+ throw unavailable("is empty")+ }+ // An unexpanded build-setting reference means the declaration chain broke+ // between the pbxproj and the processed plist.+ guard !identifier.contains("$(") else {+ throw unavailable("was not expanded at build time: \(identifier)")+ }+ return identifier+ }++ /// Resolves the declared App Group identifier from a bundle's Info.plist.+ /// An extension's `Bundle.main` is its `.appex`, so each product resolves its+ /// own derived value (Req 2.1).+ static func declaredAppGroupIdentifier(in bundle: Bundle) throws -> String {+ try declaredAppGroupIdentifier(fromInfoDictionary: bundle.infoDictionary)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 8fb4aa4..1852346 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -31,17 +31,22 @@ public struct SystemSharedContainerLocator: SharedContainerLocating { } public extension LibraryConfiguration {+ /// Resolves the App Group container root for a declared identifier.+ ///+ /// The identifier is explicit input (Decision 1): the embedding target reads+ /// its own bundle's derived value and hands it in, so the package holds no+ /// identifier literals of its own (Req 2.2). static func production(- environment: LibraryEnvironment,+ appGroupIdentifier: String, locator: any SharedContainerLocating = SystemSharedContainerLocator() ) throws -> LibraryConfiguration {- guard let root = locator.containerURL(forAppGroup: environment.appGroupIdentifier) else {+ guard let root = locator.containerURL(forAppGroup: appGroupIdentifier) else { throw LibraryRepositoryError.libraryUnavailable(- operation: "resolving \(environment.rawValue) App Group",+ operation: "resolving App Group \(appGroupIdentifier)", reason: "the configured shared container is unavailable" ) }- return LibraryConfiguration(rootDirectory: root, environment: environment)+ return LibraryConfiguration(rootDirectory: root) } }
diff --git a/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift b/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swiftindex c1804d2..102301c 100644--- a/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift+++ b/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift@@ -7,17 +7,7 @@ enum AsterismMigrationTool { static func main() async { do { let options = try Options(arguments: Array(CommandLine.arguments.dropFirst()))- let configuration: LibraryConfiguration- if let root = options.rootDirectory {- configuration = LibraryConfiguration(- rootDirectory: root,- environment: options.environment- )- } else {- configuration = try LibraryConfiguration.production(- environment: options.environment- )- }+ let configuration = LibraryConfiguration(rootDirectory: options.rootDirectory) try await V1ToV2Migrator.migrate( configuration: configuration,@@ -36,13 +26,15 @@ enum AsterismMigrationTool { } } +/// `--root` is required (Q15). The tool's only other way to find a library was to+/// resolve the host Mac's App Group container from a compiled-in identifier — a+/// path nothing needs once the identifier lives in the app's bundle, and one the+/// tool could not read anyway. private struct Options {- let rootDirectory: URL?- let environment: LibraryEnvironment+ let rootDirectory: URL init(arguments: [String]) throws { var root: URL?- var environment = LibraryEnvironment.development var index = 0 while index < arguments.count { switch arguments[index] {@@ -56,20 +48,15 @@ private struct Options { throw ToolError.invalidArguments("--root must be absolute") } root = candidate- case "--environment":- index += 1- guard index < arguments.count,- let value = LibraryEnvironment(rawValue: arguments[index]) else {- throw ToolError.invalidArguments("--environment must be personal or development")- }- environment = value default: throw ToolError.invalidArguments("unknown argument \(arguments[index])") } index += 1 }+ guard let root else {+ throw ToolError.invalidArguments("--root is required: pass the library's root directory")+ } rootDirectory = root- self.environment = environment } }
diff --git a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swiftindex fa01f42..257b913 100644--- a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift+++ b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift@@ -43,13 +43,11 @@ enum AsterismStoreTestHelper { } 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")+ guard arguments.count == 1 else {+ throw HelperError.invalidArguments("open requires a root path") } let configuration = LibraryConfiguration(- rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory),- environment: environment+ rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory) ) let repository = try await LibraryRepository.openForApp( configuration,@@ -59,13 +57,11 @@ enum AsterismStoreTestHelper { } private static func openV4Library(arguments: [String]) async throws {- guard arguments.count == 2,- let environment = LibraryEnvironment(rawValue: arguments[1]) else {- throw HelperError.invalidArguments("v4-open requires root path and environment")+ guard arguments.count == 1 else {+ throw HelperError.invalidArguments("v4-open requires a root path") } let configuration = LibraryConfiguration(- rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory),- environment: environment+ rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory) ) let (_, repository) = try await LibraryRepository.openV4ForApp( configuration,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex 81a5cd1..eac21c4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -180,7 +180,7 @@ private struct DegradedExportFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDegradedExport-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 5f87bab..555e16c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -504,7 +504,7 @@ private struct TestEnvironment { directoryHint: .isDirectory ) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swiftindex 1ae476d..060fe17 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift@@ -83,7 +83,7 @@ struct BackupV4ExportTests { let directory = FileManager.default.temporaryDirectory .appending(path: "AsterismV4ExportTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swiftindex 96abe75..db57b2f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BoundedFetchBehaviorTests.swift@@ -20,7 +20,7 @@ struct FetchedCanonicalResolutionTests { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+ let config = LibraryConfiguration(rootDirectory: directory) let repository = try await LibraryRepository.open( config, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))@@ -48,7 +48,7 @@ struct FetchedCanonicalResolutionTests { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+ let config = LibraryConfiguration(rootDirectory: directory) let repository = try await LibraryRepository.open( config, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))@@ -74,7 +74,7 @@ struct FetchedCanonicalResolutionTests { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let config = LibraryConfiguration(rootDirectory: directory, environment: .development)+ let config = LibraryConfiguration(rootDirectory: directory) let repository = try await LibraryRepository.open( config, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swiftindex 41d1f9b..e6da1a2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift@@ -412,7 +412,7 @@ private final class ParityFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCitationParity-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swiftindex 1e8f342..ed9d28f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift@@ -148,7 +148,7 @@ private final class CitedResolutionFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCitedResolution-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swiftindex f64b199..eafbdb6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift@@ -184,7 +184,7 @@ private struct ComposedCaptureFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismComposedCaptureTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swiftindex 780935a..ef81d53 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift@@ -133,7 +133,7 @@ private struct ComposedRecalcFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismRecalcTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex fdc11bd..ba0b287 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -300,7 +300,7 @@ private struct ComposedRepoFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismComposedTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swiftindex db847a3..edbce1b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift@@ -4,15 +4,16 @@ import Testing @Suite("Configuration shells") struct ConfigurationShellTests {- @Test("Personal and Development identities cannot overlap")- func environmentsAreIsolated() {- #expect(LibraryEnvironment.personal.appGroupIdentifier != LibraryEnvironment.development.appGroupIdentifier)- }+ /// Fixture identifiers. The package holds no identifier literals (Req 2.2),+ /// so nothing here may resemble a real App Group — these are inputs, and the+ /// real values are declared once in the Xcode project and guarded by the lint.+ private static let fixtureAppGroup = "group.example.fixture"+ private static let otherFixtureAppGroup = "group.example.other" @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)+ let configuration = LibraryConfiguration(rootDirectory: root) #expect(configuration.storeURL.path.hasSuffix("Library/Application Support/AsterismV2.sqlite")) #expect(configuration.legacyStoreURL.path.hasSuffix("Library/Application Support/Asterism.sqlite"))@@ -20,17 +21,81 @@ struct ConfigurationShellTests { #expect(configuration.markerURL.lastPathComponent == "AsterismV2.ready") } - @Test("currentEnvironment resolves to development under DEBUG")- func currentEnvironmentResolvesDevelopment() {- // In test builds (DEBUG), .current must resolve to .development- #expect(LibraryEnvironment.current == .development)+ // MARK: - Declared App Group reader (Req 2.1, 2.3, 3.1)++ @Test("A declared value is read back verbatim")+ func readerReturnsDeclaredValue() throws {+ let resolved = try LibraryConfiguration.declaredAppGroupIdentifier(+ fromInfoDictionary: [LibraryConfiguration.appGroupInfoPlistKey: Self.fixtureAppGroup]+ )+ #expect(resolved == Self.fixtureAppGroup)+ }++ @Test("A missing info dictionary throws, naming the key")+ func readerThrowsOnMissingInfoDictionary() {+ expectReaderFailure { try LibraryConfiguration.declaredAppGroupIdentifier(fromInfoDictionary: nil) }+ }++ @Test("A missing key throws, naming the key")+ func readerThrowsOnMissingKey() {+ expectReaderFailure {+ try LibraryConfiguration.declaredAppGroupIdentifier(fromInfoDictionary: ["SomeOtherKey": "value"])+ }+ }++ @Test("An empty declared value throws rather than falling back")+ func readerThrowsOnEmptyValue() {+ expectReaderFailure {+ try LibraryConfiguration.declaredAppGroupIdentifier(+ fromInfoDictionary: [LibraryConfiguration.appGroupInfoPlistKey: ""]+ )+ }+ }++ @Test("An unresolved build-setting expansion throws rather than being used as an identifier")+ func readerThrowsOnUnresolvedExpansion() {+ expectReaderFailure {+ try LibraryConfiguration.declaredAppGroupIdentifier(+ fromInfoDictionary: [LibraryConfiguration.appGroupInfoPlistKey: "$(ASTERISM_APP_GROUP_IDENTIFIER)"]+ )+ }+ }++ @Test("A non-string declared value throws")+ func readerThrowsOnNonStringValue() {+ expectReaderFailure {+ try LibraryConfiguration.declaredAppGroupIdentifier(+ fromInfoDictionary: [LibraryConfiguration.appGroupInfoPlistKey: 42]+ )+ } } - @Test("production(environment:locator:) throws when locator returns nil")+ /// Every failure path must be a `libraryUnavailable` naming the Info.plist key,+ /// so a misconfigured build says which key to look at (Req 2.3).+ private func expectReaderFailure(_ body: () throws -> String) {+ do {+ let resolved = try body()+ Issue.record("Expected a failure, got \(resolved)")+ } catch let error as LibraryRepositoryError {+ guard case .libraryUnavailable(_, let reason) = error else {+ Issue.record("Expected .libraryUnavailable, got \(error)")+ return+ }+ #expect(reason.contains(LibraryConfiguration.appGroupInfoPlistKey))+ } catch {+ Issue.record("Expected LibraryRepositoryError, got \(error)")+ }+ }++ // MARK: - production(appGroupIdentifier:locator:) (Decision 1, Req 3.1)++ @Test("production(appGroupIdentifier:locator:) throws when locator returns nil") func productionThrowsOnLocatorFailure() { let failing = FailingContainerLocator() #expect(throws: LibraryRepositoryError.self) {- _ = try LibraryConfiguration.production(environment: .development, locator: failing)+ _ = try LibraryConfiguration.production(+ appGroupIdentifier: Self.fixtureAppGroup, locator: failing+ ) } } @@ -42,7 +107,9 @@ struct ConfigurationShellTests { // Attempt production resolution — must throw do {- _ = try LibraryConfiguration.production(environment: .development, locator: failing)+ _ = try LibraryConfiguration.production(+ appGroupIdentifier: Self.fixtureAppGroup, locator: failing+ ) Issue.record("Expected production() to throw") } catch { // Expected@@ -52,13 +119,23 @@ struct ConfigurationShellTests { #expect(!FileManager.default.fileExists(atPath: sentinel.path)) } - @Test("production(environment:locator:) returns correct root from locator")+ @Test("production(appGroupIdentifier: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)+ let config = try LibraryConfiguration.production(+ appGroupIdentifier: Self.fixtureAppGroup, locator: locator+ ) #expect(config.rootDirectory == root)- #expect(config.environment == .development)+ }++ @Test("The injected identifier is the one handed to the locator")+ func productionPassesIdentifierToLocator() throws {+ let recording = RecordingContainerLocator(url: URL(filePath: "/tmp/asterism-recording"))+ _ = try LibraryConfiguration.production(+ appGroupIdentifier: Self.otherFixtureAppGroup, locator: recording+ )+ #expect(recording.requested == [Self.otherFixtureAppGroup]) } } @@ -72,3 +149,17 @@ private struct StubContainerLocator: SharedContainerLocating { let url: URL func containerURL(forAppGroup identifier: String) -> URL? { url } }++private final class RecordingContainerLocator: SharedContainerLocating, @unchecked Sendable {+ let url: URL+ private(set) var requested: [String] = []++ init(url: URL) {+ self.url = url+ }++ func containerURL(forAppGroup identifier: String) -> URL? {+ requested.append(identifier)+ return url+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swiftindex 31391b0..c90f7a9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift@@ -527,7 +527,7 @@ private final class ToleranceFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDetailMergeTolerance-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swiftindex b5ffb6a..0e2d781 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift@@ -164,7 +164,7 @@ private final class FailClosedFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismFailClosed-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swiftindex c7a6c1a..e9a5614 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift@@ -312,7 +312,7 @@ private final class LibraryFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismIdentityLookup-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swiftindex 7f6de26..8f7048e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift@@ -34,7 +34,7 @@ struct M2ScaleHarnessTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: root) }- let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: root) let repository = try await LibraryRepository.openForApp( configuration, capabilities: .m2_3@@ -66,7 +66,7 @@ struct M2ScaleHarnessTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-scale-seeder-current-\(UUID().uuidString)", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: root) }- let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: root) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swiftindex be848fb..c82332a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift@@ -51,9 +51,8 @@ struct M3ScaleFixture { // Split: one Work's Entries yield different bracket Work identities // Key collision: two Entries with same (hostname, workIdentity, sequence) tuple - /// Creates the deterministic fixture. The environment parameter validates- /// isolation but does not change outputs.- static func make(environment: LibraryEnvironment = .development) throws -> M3ScaleFixture {+ /// Creates the deterministic fixture.+ static func make() throws -> M3ScaleFixture { let hostname = ExactScalarString("scale.test") // Define the rules
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swiftindex cbd9f17..dbaa350 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift@@ -297,22 +297,15 @@ struct M3ScaleFixtureTests { #expect(first.keyCollisionPairs.count == second.keyCollisionPairs.count) } - // MARK: - Environment isolation (Requirement 7.7)-- @Test("Development and Personal environments produce identical fixture entries")- func environmentIsolation() throws {- let devFixture = try M3ScaleFixture.make(environment: .development)- let personalFixture = try M3ScaleFixture.make(environment: .personal)-- #expect(devFixture.entries.count == personalFixture.entries.count)- #expect(devFixture.entries.first?.id == personalFixture.entries.first?.id)- #expect(devFixture.entries.last?.id == personalFixture.entries.last?.id)- #expect(devFixture.bracketRuleDefinition == personalFixture.bracketRuleDefinition)- #expect(devFixture.templateRuleDefinition == personalFixture.templateRuleDefinition)- #expect(devFixture.hostname == personalFixture.hostname)- }-- @Test("Fixture hostnames are distinct from the environment's real library path")+ // MARK: - Fixture isolation+ //+ // The old relational-references Req 7.7 environment-isolation cases lived here+ // and compared two fixtures built with different `LibraryEnvironment` cases.+ // With the enum gone the comparison was `make()` against `make()`, and the+ // isolation invariant it stood for — Development and Personal never resolving+ // to the same App Group — is now `scripts/verify-identity.sh` check 1's job.++ @Test("Fixture hostnames are distinct from any real library path") func fixtureHostnamesDoNotOverlapLiveLibrary() throws { let fixture = try M3ScaleFixture.make() @@ -365,7 +358,7 @@ struct M3ScaleFixtureTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-m3-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: root) }- let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: root) let repository = try await LibraryRepository.openForApp( configuration, capabilities: .m3@@ -380,23 +373,6 @@ struct M3ScaleFixtureTests { #expect(counts.works == 890) } - @Test("Repository seeder in Personal environment produces the same graph")- func repositorySeederPersonalEnvironment() async throws {- let root = FileManager.default.temporaryDirectory- .appending(path: "asterism-m3-scale-personal-\(UUID().uuidString)", directoryHint: .isDirectory)- defer { try? FileManager.default.removeItem(at: root) }- let configuration = LibraryConfiguration(rootDirectory: root, environment: .personal)- let repository = try await LibraryRepository.openForApp(- configuration,- capabilities: .m3- )-- try await repository.seedM3PerformanceFixture()-- let counts = try await repository.debugCounts()- #expect(counts.entries == 5_000)- }- // MARK: - Signpost compatibility @Test("M3 performance signpost names stay compatible with the physical hooks")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swiftindex fb9808e..000622e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift@@ -302,7 +302,7 @@ private final class M4MigrationStore { root = FileManager.default.temporaryDirectory .appending( path: "asterism-m4-migration-perf-\(UUID().uuidString)", directoryHint: .isDirectory)- configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: root) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex 4ed4a27..4f797e7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -79,7 +79,7 @@ struct M4ScaleFixtureTests { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-m4-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: root) }- let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: root) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swiftindex 20b7f56..ec45e86 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift@@ -198,7 +198,7 @@ struct M4ScalePerformanceTests { private func seedReadyStore() async throws -> (LibraryConfiguration, URL) { let root = FileManager.default.temporaryDirectory .appending(path: "asterism-m4-perf-\(UUID().uuidString)", directoryHint: .isDirectory)- let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: root) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swiftindex da326cc..e2976c9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift@@ -109,7 +109,7 @@ private final class M4ToleratedFixtureLibrary { init(state: M4ToleratedFixtureState) async throws { directory = FileManager.default.temporaryDirectory .appending(path: "asterism-m4-tolerated-\(UUID().uuidString)", directoryHint: .isDirectory)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swiftindex bdc86fd..27077c9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift@@ -404,7 +404,7 @@ private final class M4PerformanceStore { root = FileManager.default.temporaryDirectory .appending( path: "asterism-m4-tolerated-perf-\(UUID().uuidString)", directoryHint: .isDirectory)- configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: root) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftindex d137151..329bcc2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -28,7 +28,7 @@ struct MarkerContractTests { private func config() throws -> (TempDir, LibraryConfiguration) { let dir = try TempDir()- return (dir, LibraryConfiguration(rootDirectory: dir.url, environment: .development))+ return (dir, LibraryConfiguration(rootDirectory: dir.url)) } /// A first run: creates an empty store and marks it ready at birth. An
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swiftindex 7cb396c..2458b1b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift@@ -296,7 +296,7 @@ private struct FetcherTestFixture { 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)+ let configuration = LibraryConfiguration(rootDirectory: directory) repository = try await LibraryRepository.open( configuration, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swiftindex 0e87333..a072530 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift@@ -18,7 +18,7 @@ struct QuarantineScopingTests { let directory = FileManager.default.temporaryDirectory .appending(path: "AsterismQuarantineTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swiftindex f16449a..be684ef 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift@@ -542,7 +542,7 @@ private final class RecentToleranceFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismRecentTolerance-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex eae38fd..dcec955 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -344,7 +344,7 @@ private final class RefreshFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismRefreshUnion-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swiftindex 9710f87..c81eb9b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift@@ -54,12 +54,14 @@ struct RepositoryBootstrapTests { _ = lease } - @Test("Personal and Development resolve isolated roots and stores")+ /// Distinct roots stay isolated at every derived path. The App Group+ /// inequality that used to sit at the top of this case is gone with the enum;+ /// that invariant is `scripts/verify-identity.sh` check 1's job now.+ @Test("Distinct roots resolve isolated 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)+ let personal = LibraryConfiguration(rootDirectory: base.appending(path: "personal"))+ let development = LibraryConfiguration(rootDirectory: base.appending(path: "development")) #expect(personal.storeURL != development.storeURL) #expect(personal.lockURL != development.lockURL) #expect(personal.markerURL != development.markerURL)@@ -68,7 +70,7 @@ struct RepositoryBootstrapTests { @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 configuration = LibraryConfiguration(rootDirectory: directory.url) let repository = try await LibraryRepository.open(configuration) #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path)) #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))@@ -78,7 +80,7 @@ struct RepositoryBootstrapTests { @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)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try Data().write(to: configuration.markerURL) await #expect(throws: LibraryRepositoryError.self) {@@ -90,8 +92,8 @@ struct RepositoryBootstrapTests { @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"])+ let configuration = LibraryConfiguration(rootDirectory: directory.url)+ try runHelperAndRequireSuccess(["open", directory.url.path]) try FileManager.default.removeItem(at: configuration.markerURL) let repository = try await LibraryRepository.open(configuration)@@ -102,7 +104,7 @@ struct RepositoryBootstrapTests { @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)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try FileManager.default.createDirectory(at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true) let evidence = Data("not a sqlite store".utf8) try evidence.write(to: configuration.storeURL)@@ -118,8 +120,8 @@ struct RepositoryBootstrapTests { @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"])+ let first = try HelperProcess(arguments: ["open", directory.url.path])+ let second = try HelperProcess(arguments: ["open", directory.url.path]) try first.run() try second.run() first.waitUntilExit()@@ -127,7 +129,7 @@ struct RepositoryBootstrapTests { #expect(first.terminationStatus == 0) #expect(second.terminationStatus == 0) - let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) let repository = try await LibraryRepository.open(configuration) #expect(try await repository.debugCounts() == .zero) }@@ -136,7 +138,7 @@ struct RepositoryBootstrapTests { func retainedContainerAndCoherentRead() async throws { let directory = try TemporaryDirectory() let repository = try await LibraryRepository.open(- LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ LibraryConfiguration(rootDirectory: directory.url) ) for _ in 0..<10 { #expect(try await repository.debugCounts() == .zero)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swiftindex 4967ee2..930d612 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift@@ -183,7 +183,7 @@ private struct CaptureRepositoryFixture { saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy() ) async throws { directory = try CaptureTemporaryDirectory()- configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory.url) repository = try await LibraryRepository.open(configuration, clock: clock, saveStrategy: saveStrategy) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swiftindex c9e63f3..9463970 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift@@ -566,7 +566,7 @@ private struct ReShareFixture { saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy() ) async throws { directory = try ReShareTemporaryDirectory()- configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory.url) repository = try await LibraryRepository.open(configuration, clock: clock, saveStrategy: saveStrategy) } @@ -586,7 +586,7 @@ private struct ReShareFixtureWithRule { init() async throws { directory = try ReShareTemporaryDirectory()- configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory.url) repository = try await LibraryRepository.open(configuration) // Seed a Site with a URL rule via the existing teaching flow
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftindex 29ec12d..84f9785 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -1466,7 +1466,7 @@ private struct TeachingFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismTeachingTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) repository = try await LibraryRepository.open( configuration, capabilities: capabilities,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swiftindex 808e995..3049a21 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift@@ -206,7 +206,7 @@ private struct WorksRepositoryFixture { ) async throws { directory = try WorksTemporaryDirectory() repository = try await LibraryRepository.open(- LibraryConfiguration(rootDirectory: directory.url, environment: .development),+ LibraryConfiguration(rootDirectory: directory.url), clock: clock, saveStrategy: saveStrategy )
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swiftindex edf8a9b..4804795 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift@@ -268,7 +268,7 @@ private struct ReteachFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismReteachTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swiftindex e6d8d1f..f9ecf4e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift@@ -7,7 +7,7 @@ struct RuntimeOpeningV2Tests { @Test("Containing app creates and marks a fresh V2 library") func appCreatesV2() async throws { let directory = try V2OpeningTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) let repository = try await LibraryRepository.openForApp(configuration, capabilities: .m2_0) @@ -20,7 +20,7 @@ struct RuntimeOpeningV2Tests { @Test("Extension refuses to create or mark an absent V2 library") func extensionDoesNotCreate() async throws { let directory = try V2OpeningTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) await #expect(throws: LibraryRepositoryError.self) { try await LibraryRepository.openForExtension(configuration, capabilities: .m2_0)@@ -33,7 +33,7 @@ struct RuntimeOpeningV2Tests { @Test("Extension opens only an existing ready V2 library") func extensionOpensReadyV2() async throws { let directory = try V2OpeningTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) _ = try await LibraryRepository.openForApp(configuration, capabilities: .m2_0) let extensionRepository = try await LibraryRepository.openForExtension(@@ -47,7 +47,7 @@ struct RuntimeOpeningV2Tests { @Test("App preserves a legacy V1 migration opportunity instead of creating V2") func appRefusesLegacyPath() async throws { let directory = try V2OpeningTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try FileManager.default.createDirectory( at: configuration.legacyStoreURL.deletingLastPathComponent(), withIntermediateDirectories: true@@ -67,7 +67,7 @@ struct RuntimeOpeningV2Tests { @Test("Ready marker without V2 store fails closed") func markerWithoutStore() async throws { let directory = try V2OpeningTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try Data("ready".utf8).write(to: configuration.markerURL) await #expect(throws: LibraryRepositoryError.self) {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swiftindex 725345c..2d6b1ec 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareTransportTests.swift@@ -398,7 +398,7 @@ private struct ShareTransportFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismShareTransportTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) repository = try await LibraryRepository.open( configuration, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swiftindex 5d6cf79..fa4c62f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift@@ -9,7 +9,7 @@ struct V1ToV2MigrationTests { @Test("Representative V1 graph reopens as logically equal V2 with segment mapping") func representativeGraph() async throws { let directory = try MigrationTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try createRepresentativeV1Store(at: configuration.legacyStoreURL) let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) @@ -41,7 +41,7 @@ struct V1ToV2MigrationTests { @Test("Existing V2 destination is refused without changing either store") func existingDestinationRefused() async throws { let directory = try MigrationTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try createRepresentativeV1Store(at: configuration.legacyStoreURL) try FileManager.default.createDirectory( at: configuration.storeURL.deletingLastPathComponent(),@@ -66,7 +66,7 @@ struct V1ToV2MigrationTests { @Test("Destination created while lock acquisition waits is preserved") func destinationCreatedWhileWaitingForLockIsPreserved() async throws { let directory = try MigrationTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try createRepresentativeV1Store(at: configuration.legacyStoreURL) let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) var heldLease: LockLease? = try await CrossProcessLibraryLock.acquire(@@ -126,7 +126,7 @@ struct V1ToV2MigrationTests { @Test("Failed verification removes every partial V2 artifact and preserves V1") func failedVerificationCleanup() async throws { let directory = try MigrationTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try createRepresentativeV1Store(at: configuration.legacyStoreURL) let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) @@ -148,7 +148,7 @@ struct V1ToV2MigrationTests { @Test("Open runtime process blocks migration before either store is touched") func runtimeProcessBlocksMigration() async throws { let directory = try MigrationTemporaryDirectory()- let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: directory.url) try createRepresentativeV1Store(at: configuration.legacyStoreURL) let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swiftindex b12b600..09c6721 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift@@ -26,7 +26,7 @@ struct V4MigrationBootstrapTests { private func config() throws -> (TempDir, LibraryConfiguration) { let dir = try TempDir()- return (dir, LibraryConfiguration(rootDirectory: dir.url, environment: .development))+ return (dir, LibraryConfiguration(rootDirectory: dir.url)) } private static let ts = Date(timeIntervalSince1970: 1_800_000_000)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swiftindex fe29891..16f4979 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift@@ -30,7 +30,7 @@ struct V5CertificationPathTests { private func config() throws -> (TempDir, LibraryConfiguration) { let dir = try TempDir()- return (dir, LibraryConfiguration(rootDirectory: dir.url, environment: .development))+ return (dir, LibraryConfiguration(rootDirectory: dir.url)) } private static let ts = Date(timeIntervalSince1970: 1_800_000_000)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swiftindex 57ccc03..c19e67a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift@@ -35,7 +35,7 @@ struct V5RelationshipPassTests { private func config() throws -> (TempDir, LibraryConfiguration) { let dir = try TempDir()- let configuration = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+ let configuration = LibraryConfiguration(rootDirectory: dir.url) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) return (dir, configuration)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swiftindex 0544f75..1ebbdcb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift@@ -249,7 +249,7 @@ private struct MergeFixture { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) save = InstrumentedSaveStrategy() repository = try await LibraryRepository.open(- LibraryConfiguration(rootDirectory: directory, environment: .development),+ LibraryConfiguration(rootDirectory: directory), capabilities: .m3, clock: clock, saveStrategy: save
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swiftindex 66c815a..d9d04ae 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift@@ -238,7 +238,7 @@ private struct WorkURLFixture { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) save = InstrumentedSaveStrategy() repository = try await LibraryRepository.open(- LibraryConfiguration(rootDirectory: directory, environment: .development),+ LibraryConfiguration(rootDirectory: directory), capabilities: .m3, clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000)), saveStrategy: save
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WritePathQuarantineTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WritePathQuarantineTests.swiftindex 251a80b..98d949f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WritePathQuarantineTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WritePathQuarantineTests.swift@@ -270,7 +270,7 @@ private struct WritePathFixture { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismWritePathTests-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ configuration = LibraryConfiguration(rootDirectory: directory) try FileManager.default.createDirectory( at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true) container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex 8aa6832..aea1cf7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -31,7 +31,7 @@ struct WriteSiteRelationshipTests { private static let ts = Date(timeIntervalSince1970: 1_800_000_000) private func configuration(_ dir: TempDir) -> LibraryConfiguration {- LibraryConfiguration(rootDirectory: dir.url, environment: .development)+ LibraryConfiguration(rootDirectory: dir.url) } /// An open, empty, certified library — the ordinary state the app's write@@ -491,7 +491,7 @@ struct FixtureRelationshipTests { arguments: ToleratedStateFixtureKind.allCases) func toleratedStateFixtureLinksItsRecords(kind: ToleratedStateFixtureKind) async throws { let dir = try TempDir()- let cfg = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+ let cfg = LibraryConfiguration(rootDirectory: dir.url) let (_, repository) = try await LibraryRepository.openV4ForApp(cfg, capabilities: .m4) try await repository.seedToleratedStateFixture(kind) @@ -519,7 +519,7 @@ struct FixtureRelationshipTests { @Test(".siteMissing leaves its Entries with a nil relationship, deliberately") func siteMissingKeepsItsOrphansUnlinked() async throws { let dir = try TempDir()- let cfg = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+ let cfg = LibraryConfiguration(rootDirectory: dir.url) let (_, repository) = try await LibraryRepository.openV4ForApp(cfg, capabilities: .m4) try await repository.seedToleratedStateFixture(.siteMissing)
diff --git a/docs/investigations/configuration-identity-xcent.md b/docs/investigations/configuration-identity-xcent.mdnew file mode 100644index 0000000..679da92--- /dev/null+++ b/docs/investigations/configuration-identity-xcent.md@@ -0,0 +1,148 @@+# Investigation: Processed entitlements (`.xcent`) per platform++**Status:** complete — measured 2026-07-30.+**Purpose:** scope build check 2 of `specs/configuration-identity/` (design →+Verification). No artifact on this machine post-dated the iCloud entitlement+keys, so the check's platform scope — which keys the processed entitlements+actually carry on simulator versus device — was unmeasured. It is measured here.+**Retain** while `scripts/verify-build-identity.sh` exists: it is the record of+why that script reads the file it reads and escapes key paths the way it does.++---++## Method++Xcode 26.6 (17F113), macOS 26.5.1 (25F80), unmodified entitlements (the four+per-configuration files still in the tree at measurement time).++Four builds, all on this Mac, nothing installed or run on hardware:++| Build | Command |+|---|---|+| Development, simulator | `make build` |+| Development, device | `xcodebuild build -project Asterism/Asterism.xcodeproj -scheme "Asterism Development" -destination 'generic/platform=iOS' -configuration Development -derivedDataPath ./DerivedData` |+| Personal, simulator | `make build-release` |+| Personal, device | same `xcodebuild build` line with `-scheme "Asterism Personal" -configuration Personal` |++`generic/platform=iOS` is a compile-and-sign step only; it resolves no device and+contacts none. All four succeeded without `-allowProvisioningUpdates`.++Artifacts inspected with `plutil -p` under+`DerivedData/Build/Intermediates.noindex/Asterism.build/<Config>-<sdk>/<Target>.build/`,+which is exactly `$(TARGET_TEMP_DIR)`.++## Which files exist++**The device/simulator asymmetry is not "one file per platform".**++| Platform | `$(FULL_PRODUCT_NAME).xcent` | `$(FULL_PRODUCT_NAME)-Simulated.xcent` |+|---|---|---|+| `iphoneos` | present, full contents | **absent** |+| `iphonesimulator` | **present but an empty `<dict/>`** (181 bytes) | present, full contents |++Both platforms also emit a `.xcent.der` alongside each `.xcent` (7 bytes for the+empty one). The design's statement that "only one exists per platform" holds for+device only. On simulator both exist and the plain one is empty, so a script that+reads `$(FULL_PRODUCT_NAME).xcent` unconditionally does not fail on simulator —+it silently passes nothing to compare, or fails on a missing key rather than a+wrong value. The SDK-conditional `ASTERISM_XCENT_SUFFIX` is therefore load-bearing+for correctness, not just for sandbox input declaration.++On simulator the *effective* entitlements are the `-Simulated` ones: they are+linked into the product binary as a `__TEXT,__entitlements` section (decoded from+`Asterism.app/Asterism`, byte-identical in content to+`Asterism.app-Simulated.xcent`), while `codesign -d --entitlements -` on the+built `.app` and `.appex` reports an empty dict — the empty plain `.xcent` is+what the simulator code signature carries. So `-Simulated.xcent` is the file+whose contents govern what the simulator process is actually granted, and it is+the right thing to check.++## Keys carried, per platform and target++App target (`Asterism.app`), Development values shown; Personal is identical with+`.dev` dropped from every identifier.++| Key | device `.xcent` | simulator `-Simulated.xcent` |+|---|---|---|+| `application-identifier` | `V24684SCZN.me.nore.ig.Asterism.dev` | same |+| `aps-environment` | `development` | `development` |+| `com.apple.developer.aps-environment` | **absent** | `development` |+| `com.apple.developer.icloud-container-identifiers` | `[iCloud.me.nore.ig.Asterism.dev]` | same |+| `com.apple.developer.icloud-services` | `[CloudKit]` | same |+| `com.apple.security.application-groups` | `[group.me.nore.ig.Asterism.dev]` | same |+| `com.apple.developer.team-identifier` | `V24684SCZN` | **absent** |+| `get-task-allow` | `true` | **absent** |++Share extension target (`AsterismShareExtension.appex`):++| Key | device `.xcent` | simulator `-Simulated.xcent` |+|---|---|---|+| `application-identifier` | `V24684SCZN.me.nore.ig.Asterism.dev.ShareExtension` | same |+| `com.apple.security.application-groups` | `[group.me.nore.ig.Asterism.dev]` | same |+| `com.apple.developer.team-identifier` | `V24684SCZN` | **absent** |+| `get-task-allow` | `true` | **absent** |++No iCloud key on the extension on either platform, as intended (requirements+Non-Goal 3).++The Personal builds carry the same key *set* as Development on both platforms,+with `iCloud.me.nore.ig.Asterism` / `group.me.nore.ig.Asterism` /+`V24684SCZN.me.nore.ig.Asterism`. `aps-environment` is `development` in Personal+too, because both source entitlements files say so today. `get-task-allow` is+`true` in the Personal device build as well — this is a local development build,+not an App Store export.++## Consequences for build check 2++1. **Both array checks apply on both platforms, unscoped.**+ `com.apple.security.application-groups` is present on device and simulator, on+ both targets; `com.apple.developer.icloud-container-identifiers` is present on+ device and simulator for the app target. The design left the array checks'+ platform scope conditional on this measurement — no condition is needed. The+ check reads the platform's one *populated* file and asserts both arrays.+2. **The file to read is platform-selected, and a missing file is a hard error.**+ Device: `$(TARGET_TEMP_DIR)/$(FULL_PRODUCT_NAME).xcent`. Simulator:+ `$(TARGET_TEMP_DIR)/$(FULL_PRODUCT_NAME)-Simulated.xcent`. Reading the plain+ file on simulator reads an empty dict, which is the exact failure mode the+ "hard-fail, never skip" rule exists to prevent — and it would not be caught by+ an existence test, only by asserting the expected keys are present.+3. **`plutil -extract` key paths must escape the dots in the entitlement key+ names.** Measured: `plutil -extract com.apple.security.application-groups.0 raw`+ **fails** with "No value at that key path" on a file that plainly has the key —+ plutil splits the path on `.`, so it looks for `com` → `apple` → … The working+ form is+ `plutil -extract 'com\.apple\.security\.application-groups.0' raw -o - <file>`+ (single-quoted; the backslashes go to plutil, not the shell), likewise+ `'com\.apple\.developer\.icloud-container-identifiers.0'`. This is not a+ cosmetic detail: the unescaped form fails identically on a correct build and a+ broken one, i.e. it is a check that can only ever produce a false alarm, and if+ its exit status were used the other way round, one that can never fire.+4. **Index 1 failing to extract does prove single-element**, as the design+ assumed: index `.0` extracts, index `.1` exits 1 on every file measured. A+ cheaper equivalent is available — `plutil -extract '<escaped key>' raw` on the+ array itself prints the element *count* (`1`), so an exact `= 1` comparison+ proves the length in one call. Either form works; the count form gives a+ better diagnostic ("2 groups declared" rather than "index 1 unexpectedly+ extracted").+5. `com.apple.developer.aps-environment` is dropped by the device build (it is+ macOS-only and the device entitlements are intersected with the provisioning+ profile) and survives verbatim on simulator. Deleting it from the source files+ per Q18 therefore changes the device `.xcent` not at all, and removes a key+ from the simulator `-Simulated.xcent` that nothing reads. The check must not+ assert on it either way.+6. Device builds add `com.apple.developer.team-identifier` and `get-task-allow`+ that the source file never declared, and simulator omits them. Any check+ written as "the processed entitlements equal the source entitlements" would+ fail on both platforms for different reasons. Check 2 must stay key-scoped:+ exact equality of the two identity arrays, silence about everything else.++## Not covered++- No export/archive path was measured. `make archive` and an App Store export+ intersect with a distribution profile, which will change+ `aps-environment`/`get-task-allow`; the check asserts only the two identity+ arrays, which a profile intersection cannot alter without failing to sign.+- visionOS (`xros`, `xrsimulator`) is in `SUPPORTED_PLATFORMS` but was not built.+ This is why the script's platform branch keys off "`PLATFORM_NAME` ends in+ `simulator`" rather than testing for `iphoneos`.+- Nothing was installed on or run against a physical device.
diff --git a/scripts/verify-build-identity.sh b/scripts/verify-build-identity.shnew file mode 100755index 0000000..551ae9e--- /dev/null+++ b/scripts/verify-build-identity.sh@@ -0,0 +1,325 @@+#!/bin/bash+#+# verify-build-identity.sh — the in-build identity check (Req 4.1, Req 4.2).+#+# Run as the "Verify Configuration Identity" build phase of the app and the share+# extension, so every build of either configuration checks itself. It is the only+# identity guard Personal ever reaches: Personal runs no unit tests, so the+# `make test-core` lint bar never gates it (`make build-release` is the routine+# way to exercise this script for Personal).+#+# What it proves that `scripts/verify-identity.sh` cannot: the *processed*+# entitlements — the file that becomes the product's signature or its embedded+# `__TEXT,__entitlements` section — carry exactly the declared identifiers. The+# lint reads source files; between them and the signed product sit build-setting+# expansion, per-SDK conditions and the provisioning intersection.+#+# Five things to know before editing. All five were measured (Xcode 26.6) and+# written up in docs/investigations/configuration-identity-xcent.md; each one is+# a way this script can pass while the build is wrong:+#+# * On simulator BOTH .xcent files exist and the unsuffixed one is an empty+# dict; `-Simulated.xcent` is the file whose contents govern the process.+# Reading `$(FULL_PRODUCT_NAME).xcent` unconditionally therefore does not+# fail on simulator, it passes on nothing. The SDK-conditional build setting+# ASTERISM_XCENT_SUFFIX selects the populated file, and check 0 below asserts+# that selection actually happened rather than trusting it.+#+# * plutil key paths split on `.`, and every entitlement key is full of dots.+# Unescaped, `-extract com.apple.security.application-groups.0` fails on a+# perfectly correct file — a check that can only false-alarm, or, with its+# exit status read the other way round, never fire. Keys go through+# plutil_keypath().+#+# * Expected values are recomputed from ASTERISM_IDENTITY, never taken from the+# derived settings (Q20). Comparing the entitlements against+# ASTERISM_APP_GROUP_IDENTIFIER only proves internal consistency: a+# derivation typo that expands an undefined variable agrees with itself.+#+# * The comparison is key-scoped, never whole-file. Device signing adds+# `com.apple.developer.team-identifier` and `get-task-allow` that no source+# file declares, and drops the macOS-only+# `com.apple.developer.aps-environment` that simulator keeps. Only the two+# identity arrays are asserted — plus the absence of both CloudKit keys on the+# extension; everything else is deliberately unmentioned.+#+# * Absence is a hard failure, never a skip. Both identity arrays are present+# on both platforms, so there is no legitimate "not applicable" case, and a+# guard that skips is a guard that is silently broken.++set -uo pipefail++readonly IDENTITY_SETTING="ASTERISM_IDENTITY"+readonly APP_GROUP_PLIST_KEY="AsterismAppGroupIdentifier"+readonly CONTAINER_PLIST_KEY="AsterismCloudKitContainerIdentifier"+readonly STORE_PATH_PLIST_KEY="AsterismStoreRelativePath"+readonly GROUPS_ENTITLEMENT="com.apple.security.application-groups"+readonly CONTAINERS_ENTITLEMENT="com.apple.developer.icloud-container-identifiers"+readonly SERVICES_ENTITLEMENT="com.apple.developer.icloud-services"++failures=0++# Xcode surfaces an `error:`-prefixed line as a build error, so the diagnostic+# lands in the issue navigator instead of only in the build log.+fail() {+ printf 'error: verify-build-identity [%s/%s]: %s\n' \+ "${TARGET_NAME:-unknown target}" "${CONFIGURATION:-unknown configuration}" "$1"+ failures=$((failures + 1))+}++# An unresolved reference is the measured silent-failure mode: an undefined+# build setting expands to nothing, or the literal `$(...)` reaches the product.+has_unresolved_reference() {+ case "$1" in+ *'$('*) return 0 ;;+ esac+ return 1+}++plutil_keypath() {+ printf '%s' "${1//./\\.}"+}++# ------------------------------------------------------------------------------+# Check 0 — the phase itself is wired to the files it thinks it is reading.+#+# ENABLE_USER_SCRIPT_SANDBOXING = YES means the declared inputs are the only+# paths readable, and they are also what orders this phase after+# ProcessProductPackaging — phase position alone guarantees nothing. So the+# inputs are the check's foundation, and a wrong one makes every check below+# vacuous rather than failing. Recomposing the expected paths here is what+# catches an edited input declaration, in particular a dropped SDK condition+# that would point check 2 at the empty simulator .xcent.+# ------------------------------------------------------------------------------++expects_container="unknown"++check_wiring() {+ local expected_xcent expected_plist setting++ case "${PRODUCT_TYPE:-}" in+ com.apple.product-type.application) expects_container="yes" ;;+ com.apple.product-type.app-extension) expects_container="no" ;;+ *)+ fail "unexpected PRODUCT_TYPE '${PRODUCT_TYPE:-}'; this phase belongs on the app and the share extension only, and the script must be taught any new target rather than guess whether it owns a CloudKit container"+ ;;+ esac++ if [ "${SCRIPT_INPUT_FILE_COUNT:-0}" != "3" ]; then+ fail "the phase declares ${SCRIPT_INPUT_FILE_COUNT:-0} input file(s), expected 3 (the processed entitlements, the source Info.plist, this script)"+ return 1+ fi++ for setting in TARGET_TEMP_DIR FULL_PRODUCT_NAME SRCROOT INFOPLIST_FILE PLATFORM_NAME; do+ if [ -z "$(printenv "$setting" || true)" ]; then+ fail "build setting $setting is empty; the script cannot recompose the paths it is meant to read"+ return 1+ fi+ done++ # Empty by default, "-Simulated" under [sdk=*simulator*]. The platform test+ # is "ends in simulator", never a two-way iphoneos test: SUPPORTED_PLATFORMS+ # includes visionOS, whose simulator is xrsimulator.+ case "$PLATFORM_NAME" in+ *simulator)+ if [ "${ASTERISM_XCENT_SUFFIX:-}" != "-Simulated" ]; then+ fail "ASTERISM_XCENT_SUFFIX is '${ASTERISM_XCENT_SUFFIX:-}' on platform $PLATFORM_NAME, expected '-Simulated'; the unsuffixed simulator .xcent is an empty dict, so this check would pass on nothing"+ return 1+ fi+ ;;+ *)+ if [ -n "${ASTERISM_XCENT_SUFFIX:-}" ]; then+ fail "ASTERISM_XCENT_SUFFIX is '${ASTERISM_XCENT_SUFFIX}' on platform $PLATFORM_NAME, expected it to be empty; only simulator builds emit a -Simulated.xcent"+ return 1+ fi+ ;;+ esac++ expected_xcent="$TARGET_TEMP_DIR/$FULL_PRODUCT_NAME${ASTERISM_XCENT_SUFFIX:-}.xcent"+ expected_plist="$SRCROOT/$INFOPLIST_FILE"++ if [ "${SCRIPT_INPUT_FILE_0:-}" != "$expected_xcent" ]; then+ fail "input file 0 is '${SCRIPT_INPUT_FILE_0:-}', expected the processed entitlements at '$expected_xcent'"+ return 1+ fi+ if [ "${SCRIPT_INPUT_FILE_1:-}" != "$expected_plist" ]; then+ fail "input file 1 is '${SCRIPT_INPUT_FILE_1:-}', expected the source Info.plist at '$expected_plist'"+ return 1+ fi+ case "${SCRIPT_INPUT_FILE_2:-}" in+ */verify-build-identity.sh) ;;+ *) fail "input file 2 is '${SCRIPT_INPUT_FILE_2:-}', expected this script; without it declared, the sandbox cannot read the script it is running" ;;+ esac++ return 0+}++# ------------------------------------------------------------------------------+# Check 1 — the declaration and its derivations, as this build resolved them.+# ------------------------------------------------------------------------------++expected_group=""+expected_container=""++check_settings() {+ local identity="${ASTERISM_IDENTITY:-}"+ local group="${ASTERISM_APP_GROUP_IDENTIFIER:-}"+ local container="${ASTERISM_ICLOUD_CONTAINER_IDENTIFIER:-}"++ if [ -z "$identity" ]; then+ fail "$IDENTITY_SETTING is empty; every identifier derives from it, so nothing below can be checked"+ return 1+ fi+ if has_unresolved_reference "$identity"; then+ fail "$IDENTITY_SETTING is '$identity', which still contains an unresolved reference"+ return 1+ fi++ expected_group="group.$identity"+ expected_container="iCloud.$identity"++ if [ -z "$group" ]; then+ fail "ASTERISM_APP_GROUP_IDENTIFIER is empty, expected '$expected_group'"+ elif has_unresolved_reference "$group"; then+ fail "ASTERISM_APP_GROUP_IDENTIFIER is '$group', which still contains an unresolved reference"+ elif [ "$group" != "$expected_group" ]; then+ fail "ASTERISM_APP_GROUP_IDENTIFIER is '$group', expected '$expected_group' derived from $IDENTITY_SETTING"+ fi++ if [ "$expects_container" = "yes" ]; then+ if [ -z "$container" ]; then+ fail "ASTERISM_ICLOUD_CONTAINER_IDENTIFIER is empty, expected '$expected_container'"+ elif has_unresolved_reference "$container"; then+ fail "ASTERISM_ICLOUD_CONTAINER_IDENTIFIER is '$container', which still contains an unresolved reference"+ elif [ "$container" != "$expected_container" ]; then+ fail "ASTERISM_ICLOUD_CONTAINER_IDENTIFIER is '$container', expected '$expected_container' derived from $IDENTITY_SETTING"+ fi+ fi++ # The whole runtime chain rests on this setting's default: with it off, the+ # source plist's $(...) references reach the product verbatim and the app+ # dies at launch on an unresolvable App Group.+ if [ "${INFOPLIST_EXPAND_BUILD_SETTINGS:-YES}" = "NO" ]; then+ fail "INFOPLIST_EXPAND_BUILD_SETTINGS is NO; the Info.plist identity keys would ship as unexpanded \$(...) references"+ fi++ return 0+}++# ------------------------------------------------------------------------------+# Check 2 — the processed entitlements this product will be signed with.+#+# Exact single-element equality, not containment (Q6): an entitlement listing+# both configurations' App Groups is the catastrophic case — the build signs+# successfully and the app can reach the other configuration's library — and a+# containment check waves it through.+# ------------------------------------------------------------------------------++check_entitlements() {+ local xcent="${SCRIPT_INPUT_FILE_0:-}"++ if [ ! -f "$xcent" ]; then+ fail "no processed entitlements at $xcent; the file is produced by ProcessProductPackaging before this phase, so its absence means the check is not running where it thinks it is — never a reason to skip"+ return+ fi++ check_xcent_array "$xcent" "$GROUPS_ENTITLEMENT" "$expected_group"++ if [ "$expects_container" = "yes" ]; then+ check_xcent_array "$xcent" "$CONTAINERS_ENTITLEMENT" "$expected_container"+ elif [ "$expects_container" = "no" ]; then+ # Non-Goal 3 takes both keys. The container list names the container;+ # icloud-services is what switches CloudKit on for the process, and a+ # product entitled to the service is a product that reached CloudKit —+ # whichever container it then resolves.+ local key+ for key in "$CONTAINERS_ENTITLEMENT" "$SERVICES_ENTITLEMENT"; do+ if plutil -extract "$(plutil_keypath "$key")" raw -o - "$xcent" >/dev/null 2>&1; then+ fail "$xcent grants $key; only the app target may talk to CloudKit (Non-Goal 3)"+ fi+ done+ fi+}++check_xcent_array() {+ local file="$1" key="$2" expected="$3" keypath count actual++ if [ -z "$expected" ]; then+ return+ fi++ keypath="$(plutil_keypath "$key")"++ # `-extract` on an array prints its element count, so one call proves the+ # length and gives a diagnostic naming the actual number.+ if ! count="$(plutil -extract "$keypath" raw -o - "$file" 2>/dev/null)"; then+ fail "the processed entitlements at $file do not grant $key; the product would be signed without it"+ return+ fi+ if [ "$count" != "1" ]; then+ fail "$key in $file holds $count entries, expected exactly 1 ('$expected'); more than one means this product can reach another configuration's data"+ fi+ if ! actual="$(plutil -extract "$keypath.0" raw -o - "$file" 2>/dev/null)"; then+ fail "$key in $file is not an array of identifiers"+ return+ fi+ if [ "$actual" != "$expected" ]; then+ fail "$key in $file is '$actual', expected '$expected' recomputed from $IDENTITY_SETTING = '${ASTERISM_IDENTITY:-}'"+ fi+}++# ------------------------------------------------------------------------------+# Check 3 — the source Info.plist references the settings.+#+# The processed plist does not exist yet when this phase runs (script phases run+# before ProcessInfoPlistFile), so the reference strings are what can be checked:+# with expansion on — asserted by check 1 — a reference is what puts the resolved+# value in the built bundle, and a literal here is a second declaration that can+# drift.+# ------------------------------------------------------------------------------++check_info_plist() {+ local plist="${SCRIPT_INPUT_FILE_1:-}"++ if [ ! -f "$plist" ]; then+ fail "no Info.plist at $plist"+ return+ fi++ check_plist_reference "$plist" "$APP_GROUP_PLIST_KEY" '$(ASTERISM_APP_GROUP_IDENTIFIER)'+ if [ "$expects_container" = "yes" ]; then+ check_plist_reference "$plist" "$CONTAINER_PLIST_KEY" '$(ASTERISM_ICLOUD_CONTAINER_IDENTIFIER)'+ fi++ if plutil -extract "$STORE_PATH_PLIST_KEY" raw -o - "$plist" >/dev/null 2>&1; then+ fail "$plist still carries the removed $STORE_PATH_PLIST_KEY key (Req 1.5)"+ fi+}++check_plist_reference() {+ local plist="$1" key="$2" expected="$3" actual+ if ! actual="$(plutil -extract "$key" raw -o - "$plist" 2>/dev/null)"; then+ fail "$plist has no $key; the built bundle is where the running process reads it from"+ return+ fi+ if [ "$actual" != "$expected" ]; then+ fail "$plist declares $key as '$actual', expected the reference '$expected'"+ fi+}++# ------------------------------------------------------------------------------++if check_wiring && check_settings; then+ check_entitlements+ check_info_plist+fi++if [ "$failures" -gt 0 ]; then+ printf 'verify-build-identity: %d problem(s) found in %s/%s.\n' \+ "$failures" "${TARGET_NAME:-?}" "${CONFIGURATION:-?}"+ exit 1+fi++printf 'verify-build-identity: %s/%s grants %s%s.\n' \+ "${TARGET_NAME:-?}" "${CONFIGURATION:-?}" "$expected_group" \+ "$([ "$expects_container" = "yes" ] && printf ' and %s' "$expected_container")"
diff --git a/scripts/verify-identity.sh b/scripts/verify-identity.shnew file mode 100755index 0000000..33a681b--- /dev/null+++ b/scripts/verify-identity.sh@@ -0,0 +1,557 @@+#!/bin/bash+#+# verify-identity.sh — the build-independent identity lint (Req 4.3).+#+# Every build configuration declares one identity token, ASTERISM_IDENTITY, from+# which the App Group and the CloudKit container identifier derive. This script+# checks the declarations without building any target, so it can compare+# Development against Personal in one pass — something no build-time check can do,+# since a build only ever sees one configuration.+#+# Run it as `make verify-identity`. It is also a prerequisite of `make test-core`,+# the repo's stated pre-commit bar (Q13): a guard only reachable through a target+# nobody runs is a guard that is silently broken.+#+# Three things to know before editing:+#+# * The pbxproj is parsed structurally, via `plutil -convert json`, not grepped.+# Occurrence counting cannot tell a project-level declaration from a+# target-level setting that shadows it — and Xcode silently prefers the+# shadow, which is the whole failure mode check 1 exists for.+#+# * All string matching is boundary-aware. Every Personal composed value is a+# strict prefix of its Development sibling (group.me.nore.ig.Asterism is a+# prefix of group.me.nore.ig.Asterism.dev), so a naive substring match+# corrupts every "equals" and every "appears nowhere" check in here.+#+# * A build setting can carry a condition — `ASTERISM_IDENTITY[sdk=iphoneos*]` —+# and that makes a *distinct* dictionary key. Extracting the exact keypath+# therefore looks straight past it while Xcode prefers the conditioned value+# wherever the condition matches. Every identity setting's keys are enumerated+# rather than probed by name, so a condition cannot hide an assignment.++set -uo pipefail++readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"+readonly PBXPROJ="$REPO_ROOT/Asterism/Asterism.xcodeproj/project.pbxproj"++# The pinned expected tokens (Q16). These are verification data, not a second+# declaration: without them a typo'd token keeps every other check internally+# consistent and Req 1.2 has no guard at all — with automatic signing Xcode+# registers the typo'd App Group and the app comes up on an empty library, the+# incident class this repo's CLAUDE.md exists for. An intentional identity change+# is a deliberate two-place edit, matching the Q8 banner precedent.+readonly EXPECTED_TOKEN_DEVELOPMENT="me.nore.ig.Asterism.dev"+readonly EXPECTED_TOKEN_PERSONAL="me.nore.ig.Asterism"++# The exact derivation text expected at project level, verbatim.+readonly EXPECTED_GROUP_DERIVATION='group.$(ASTERISM_IDENTITY)'+readonly EXPECTED_CONTAINER_DERIVATION='iCloud.$(ASTERISM_IDENTITY)'++readonly APP_GROUP_SETTING="ASTERISM_APP_GROUP_IDENTIFIER"+readonly CONTAINER_SETTING="ASTERISM_ICLOUD_CONTAINER_IDENTIFIER"+readonly IDENTITY_SETTING="ASTERISM_IDENTITY"++readonly APP_GROUP_PLIST_KEY="AsterismAppGroupIdentifier"+readonly CONTAINER_PLIST_KEY="AsterismCloudKitContainerIdentifier"++# Lines carrying this sentinel are exempt from the literal sweep, and only in the+# Makefile: `make` cannot expand Xcode build settings and the device-warning+# banner must stay readable by a human deciding whether to let a command touch+# their phone (Q8). The exemption is keyed on the file the hit came from — an+# exemption honoured anywhere would let any file silence the sweep by pasting the+# comment beside a literal.+readonly SWEEP_SENTINEL="# identity-lint: allowed"+readonly SWEEP_SENTINEL_FILE="Makefile"++readonly CONTAINERS_ENTITLEMENT="com.apple.developer.icloud-container-identifiers"+readonly SERVICES_ENTITLEMENT="com.apple.developer.icloud-services"+readonly GROUPS_ENTITLEMENT="com.apple.security.application-groups"++failures=0++fail() {+ printf 'verify-identity: error: %s\n' "$1" >&2+ failures=$((failures + 1))+}++note() {+ printf 'verify-identity: %s\n' "$1"+}++# ------------------------------------------------------------------------------+# pbxproj access+# ------------------------------------------------------------------------------++WORK_DIR="$(mktemp -d)"+trap 'rm -rf "$WORK_DIR"' EXIT+readonly PBX_JSON="$WORK_DIR/project.json"++if [ ! -f "$PBXPROJ" ]; then+ fail "no project file at $PBXPROJ"+ exit 1+fi++if ! plutil -convert json -o "$PBX_JSON" "$PBXPROJ" 2>/dev/null; then+ fail "could not parse $PBXPROJ as a property list"+ exit 1+fi++# Prints the value at a keypath, or nothing. Returns non-zero when absent.+pbx() {+ plutil -extract "$1" raw -o - "$PBX_JSON" 2>/dev/null+}++# One pre-pass over the converted project, held as "uuid isa name" lines.+#+# Every check below asks the same objects for their isa and their name, and each+# keypath extraction is a process: asking per question made this lint spawn ~209+# plutil processes for a file of sixty objects, most of them re-reads. The table+# is built once and matched in-shell, so the shadow scan and the target lookups+# below cost nothing.+OBJECT_TABLE=""++build_object_table() {+ local uuid isa name+ for uuid in $(pbx "objects"); do+ # Names are only read for the kinds of object this lint looks up by name.+ isa="$(pbx "objects.$uuid.isa")"+ case "$isa" in+ XCBuildConfiguration | PBXNativeTarget | PBXAggregateTarget)+ name="$(pbx "objects.$uuid.name")"+ ;;+ *) name="" ;;+ esac+ OBJECT_TABLE="$OBJECT_TABLE$uuid $isa $name"$'\n'+ done+}++# The uuids of every object whose isa is one of those named, one per line.+object_uuids_with_isa() {+ local uuid isa name wanted+ while read -r uuid isa name; do+ for wanted in "$@"; do+ [ "$isa" = "$wanted" ] || continue+ printf '%s\n' "$uuid"+ break+ done+ done <<< "$OBJECT_TABLE"+}++object_name_of() {+ local wanted="$1" uuid isa name+ while read -r uuid isa name; do+ [ "$uuid" = "$wanted" ] || continue+ printf '%s' "$name"+ return 0+ done <<< "$OBJECT_TABLE"+ return 1+}++build_object_table++# ------------------------------------------------------------------------------+# Check 1 — the token is declared exactly twice, at project level, with the+# pinned values.+# ------------------------------------------------------------------------------++project_uuid="$(pbx "rootObject")"+project_config_list="$(pbx "objects.$project_uuid.buildConfigurationList")"++if [ -z "$project_config_list" ]; then+ fail "could not resolve the project's XCConfigurationList"+ exit 1+fi++# The project's own build configurations, as "uuid name" pairs.+project_configs=""+index=0+while true; do+ uuid="$(pbx "objects.$project_config_list.buildConfigurations.$index")" || break+ [ -n "$uuid" ] || break+ name="$(object_name_of "$uuid")"+ project_configs="$project_configs$uuid $name"$'\n'+ index=$((index + 1))+done++expected_token_for() {+ case "$1" in+ Development) printf '%s' "$EXPECTED_TOKEN_DEVELOPMENT" ;;+ Personal) printf '%s' "$EXPECTED_TOKEN_PERSONAL" ;;+ *) return 1 ;;+ esac+}++check_project_level_settings() {+ local seen_development=0 seen_personal=0+ local uuid name++ while read -r uuid name; do+ [ -n "$uuid" ] || continue+ case "$name" in+ Development) seen_development=$((seen_development + 1)) ;;+ Personal) seen_personal=$((seen_personal + 1)) ;;+ *)+ fail "the project has an unexpected build configuration '$name'; the lint only knows Development and Personal"+ continue+ ;;+ esac++ local expected actual+ expected="$(expected_token_for "$name")"+ if ! actual="$(pbx "objects.$uuid.buildSettings.$IDENTITY_SETTING")" || [ -z "$actual" ]; then+ fail "$IDENTITY_SETTING is not declared in the project's $name configuration"+ continue+ fi+ if [ "$actual" != "$expected" ]; then+ fail "$IDENTITY_SETTING for $name is '$actual', expected '$expected' (pinned; change both the declaration and the pin deliberately)"+ fi++ # Check 2's derivation text, verbatim, at the same level.+ local derived+ if ! derived="$(pbx "objects.$uuid.buildSettings.$APP_GROUP_SETTING")" || [ -z "$derived" ]; then+ fail "$APP_GROUP_SETTING is not declared in the project's $name configuration"+ elif [ "$derived" != "$EXPECTED_GROUP_DERIVATION" ]; then+ fail "$APP_GROUP_SETTING for $name is '$derived', expected '$EXPECTED_GROUP_DERIVATION'"+ fi+ if ! derived="$(pbx "objects.$uuid.buildSettings.$CONTAINER_SETTING")" || [ -z "$derived" ]; then+ fail "$CONTAINER_SETTING is not declared in the project's $name configuration"+ elif [ "$derived" != "$EXPECTED_CONTAINER_DERIVATION" ]; then+ fail "$CONTAINER_SETTING for $name is '$derived', expected '$EXPECTED_CONTAINER_DERIVATION'"+ fi++ # The three checks above read exact keypaths, so a conditioned key would+ # be invisible to them while outranking the value they read.+ local key+ while IFS= read -r key; do+ [ -n "$key" ] || continue+ is_conditional_key "$key" || continue+ fail "the project's $name configuration assigns '$key'; identity settings must be unconditional — a condition makes a separate build setting that Xcode prefers wherever it matches, shadowing the pinned declaration this check just read"+ done <<< "$(identity_keys_in "$uuid")"+ done <<< "$project_configs"++ if [ "$seen_development" -ne 1 ] || [ "$seen_personal" -ne 1 ]; then+ fail "expected exactly one Development and one Personal project configuration, saw $seen_development and $seen_personal"+ fi+}++# The identity-setting keys a configuration assigns, one per line, conditional+# variants included. Enumerating the buildSettings keys is what makes+# `ASTERISM_IDENTITY[sdk=iphoneos*]` visible at all — and it is one plutil call+# for all three settings instead of one per setting.+#+# ASTERISM_XCENT_SUFFIX is deliberately SDK-conditional (verify-build-identity.sh+# explains why) and is not an identity setting, so it is not in scope here.+identity_keys_in() {+ local uuid="$1" key setting+ while IFS= read -r key; do+ [ -n "$key" ] || continue+ for setting in "$IDENTITY_SETTING" "$APP_GROUP_SETTING" "$CONTAINER_SETTING"; do+ case "$key" in+ "$setting" | "$setting"'['*) printf '%s\n' "$key" ;;+ esac+ done+ done <<< "$(pbx "objects.$uuid.buildSettings")"+}++is_conditional_key() {+ case "$1" in+ *'['*) return 0 ;;+ esac+ return 1+}++# Any assignment of an identity setting outside the project's own two+# configurations is a shadow. Xcode prefers it silently, so it defeats every+# other check in here.+check_no_shadow_assignments() {+ local uuid key name owner+ for uuid in $(object_uuids_with_isa XCBuildConfiguration); do+ case "$project_configs" in+ *"$uuid "*) continue ;;+ esac+ name="$(object_name_of "$uuid")"+ while IFS= read -r key; do+ [ -n "$key" ] || continue+ owner="$(owning_target_of "$uuid")"+ if is_conditional_key "$key"; then+ fail "'$key' is assigned in the $name configuration of ${owner:-an unidentified target}; identity settings belong to the project only, and a conditioned key is doubly invisible — the exact-keypath checks cannot see it and Xcode prefers it wherever the condition matches"+ else+ fail "$key is assigned in the $name configuration of ${owner:-an unidentified target}; identity settings belong to the project only — a target-level assignment shadows the declaration and Xcode prefers the shadow"+ fi+ done <<< "$(identity_keys_in "$uuid")"+ done+}++owning_target_of() {+ local config_uuid="$1" uuid list index candidate+ for uuid in $(object_uuids_with_isa PBXNativeTarget PBXAggregateTarget); do+ list="$(pbx "objects.$uuid.buildConfigurationList")"+ [ -n "$list" ] || continue+ index=0+ while candidate="$(pbx "objects.$list.buildConfigurations.$index")" && [ -n "$candidate" ]; do+ if [ "$candidate" = "$config_uuid" ]; then+ printf 'target %s' "$(object_name_of "$uuid")"+ return 0+ fi+ index=$((index + 1))+ done+ done+ return 1+}++# ------------------------------------------------------------------------------+# Check 3 — each target's configurations share one entitlements file, and that+# file holds references rather than literals.+# ------------------------------------------------------------------------------++# Targets that must carry an App Group, and whether the target also owns the+# CloudKit container. The share extension has no container by design: the app is+# the only process that will ever talk to CloudKit.+check_entitlements() {+ local target expects_container+ for target in "Asterism:yes" "AsterismShareExtension:no"; do+ expects_container="${target##*:}"+ target="${target%%:*}"+ check_target_entitlements "$target" "$expects_container"+ done+}++target_uuid_named() {+ local wanted="$1" uuid isa name+ while read -r uuid isa name; do+ [ "$isa" = "PBXNativeTarget" ] || continue+ [ "$name" = "$wanted" ] || continue+ printf '%s' "$uuid"+ return 0+ done <<< "$OBJECT_TABLE"+ return 1+}++check_target_entitlements() {+ local target="$1" expects_container="$2"+ local uuid list index config name file first_file="" count=0++ if ! uuid="$(target_uuid_named "$target")"; then+ fail "no target named $target"+ return+ fi+ list="$(pbx "objects.$uuid.buildConfigurationList")"+ index=0+ while config="$(pbx "objects.$list.buildConfigurations.$index")" && [ -n "$config" ]; do+ index=$((index + 1))+ name="$(object_name_of "$config")"+ if ! file="$(pbx "objects.$config.buildSettings.CODE_SIGN_ENTITLEMENTS")" || [ -z "$file" ]; then+ fail "$target's $name configuration declares no CODE_SIGN_ENTITLEMENTS"+ continue+ fi+ count=$((count + 1))+ if [ -z "$first_file" ]; then+ first_file="$file"+ elif [ "$file" != "$first_file" ]; then+ fail "$target's configurations point at different entitlements files ('$first_file' and '$file'); with identifiers as references the files are identical, so the split declares nothing (Q10)"+ fi+ done++ if [ "$count" -ne 2 ]; then+ fail "expected 2 configurations for $target, saw $count"+ fi+ [ -n "$first_file" ] || return++ # CODE_SIGN_ENTITLEMENTS is relative to SRCROOT, which is Asterism/.+ local path="$REPO_ROOT/Asterism/$first_file"+ if [ ! -f "$path" ]; then+ fail "$target's entitlements file is missing at $path"+ return+ fi++ check_entitlements_array "$path" "$GROUPS_ENTITLEMENT" "\$($APP_GROUP_SETTING)"+ if [ "$expects_container" = "yes" ]; then+ check_entitlements_array "$path" "$CONTAINERS_ENTITLEMENT" "\$($CONTAINER_SETTING)"+ else+ # Non-Goal 3 is that the extension does not talk to CloudKit at all, and+ # it takes both keys to hold: the container list names the container, and+ # icloud-services is what switches CloudKit on for the process. Either+ # one alone is an entitlement this target must not carry.+ check_entitlements_absent "$path" "$CONTAINERS_ENTITLEMENT" "a CloudKit container"+ check_entitlements_absent "$path" "$SERVICES_ENTITLEMENT" "CloudKit access"+ fi+}++check_entitlements_absent() {+ local path="$1" key="$2" description="$3"+ if plutil -extract "$(plutil_keypath "$key")" raw -o - "$path" >/dev/null 2>&1; then+ fail "$path declares $key, granting $description; only the app target may (Non-Goal 3)"+ fi+}++# plutil keypaths are dot-separated, and every entitlement key is full of dots.+# An unescaped key silently resolves to nothing, which would make the checks below+# pass vacuously on a file that declares whatever it likes.+plutil_keypath() {+ printf '%s' "${1//./\\.}"+}++# The array must hold exactly the one reference: exactly, because an entitlement+# listing both configurations' App Groups is the catastrophic case and a+# containment check passes it (Q6).+check_entitlements_array() {+ local path="$1" key="$2" expected="$3" actual keypath+ keypath="$(plutil_keypath "$key")"+ if ! actual="$(plutil -extract "$keypath.0" raw -o - "$path" 2>/dev/null)"; then+ fail "$path has no $key entry"+ return+ fi+ if [ "$actual" != "$expected" ]; then+ fail "$path declares $key as '$actual', expected the reference '$expected' — a literal here can drift from the declaration"+ fi+ if plutil -extract "$keypath.1" raw -o - "$path" >/dev/null 2>&1; then+ fail "$path declares more than one $key value; exactly one is allowed (Q6)"+ fi+}++# ------------------------------------------------------------------------------+# Check 4 — Info.plist keys, and the dead store-path key gone from the tree.+# ------------------------------------------------------------------------------++check_info_plists() {+ local app_plist="$REPO_ROOT/Asterism/Asterism/Info.plist"+ local extension_plist="$REPO_ROOT/Asterism/AsterismShareExtension/Info.plist"++ check_plist_reference "$app_plist" "$APP_GROUP_PLIST_KEY" "\$($APP_GROUP_SETTING)"+ check_plist_reference "$app_plist" "$CONTAINER_PLIST_KEY" "\$($CONTAINER_SETTING)"+ # The extension's Bundle.main is its .appex, so it carries its own derived+ # key. That is derivation from the one declaration, not a second declaration.+ check_plist_reference "$extension_plist" "$APP_GROUP_PLIST_KEY" "\$($APP_GROUP_SETTING)"++ if plutil -extract "$CONTAINER_PLIST_KEY" raw -o - "$extension_plist" >/dev/null 2>&1; then+ fail "$extension_plist carries $CONTAINER_PLIST_KEY; only the app needs it"+ fi++ # Req 1.5: the dead store-path key and its build setting, gone everywhere.+ local hit hits="$WORK_DIR/store-path-hits" status=0+ (cd "$REPO_ROOT" && git grep -l -e "AsterismStoreRelativePath" -e "ASTERISM_STORE_RELATIVE_PATH" -- \+ '*.swift' '*.plist' '*.entitlements' '*.pbxproj' 'Makefile') \+ >"$hits" 2>"$hits.err" || status=$?+ if ! git_grep_status_ok "$status" "the removed store-path key" "$hits.err"; then+ return+ fi+ while IFS= read -r hit; do+ [ -n "$hit" ] || continue+ fail "$hit still references the removed store-path key (Req 1.5)"+ done <"$hits"+}++# git grep exits 1 for "no matches" and 2 or more for "something went wrong". Read+# through a process substitution its status is never seen at all, so an erroring+# sweep reads exactly like a clean tree — a check that passes on nothing. Hence+# both sweeps below capture to a file and consult the status.+git_grep_status_ok() {+ local status="$1" subject="$2" errors="$3"+ [ "$status" -lt 2 ] && return 0+ fail "git grep failed while sweeping for $subject (exit $status): $(tr '\n' ' ' <"$errors")"+ return 1+}++check_plist_reference() {+ local path="$1" key="$2" expected="$3" actual+ if [ ! -f "$path" ]; then+ fail "no Info.plist at $path"+ return+ fi+ if ! actual="$(plutil -extract "$key" raw -o - "$path" 2>/dev/null)"; then+ fail "$path has no $key; the runtime chain reads it from the built bundle"+ return+ fi+ if [ "$actual" != "$expected" ]; then+ fail "$path declares $key as '$actual', expected the reference '$expected' — the source plist must reference the setting so the processed plist carries the resolved value"+ fi+}++# ------------------------------------------------------------------------------+# Check 5 — no full composed literal anywhere in the tracked tree.+#+# Scoped to `git ls-files` deliberately: a repo-local DerivedData/ holds processed+# plists that carry resolved literals by design, and sweeping those would fail on+# every build.+# ------------------------------------------------------------------------------++check_literal_sweep() {+ local value+ for value in \+ "group.$EXPECTED_TOKEN_DEVELOPMENT" \+ "group.$EXPECTED_TOKEN_PERSONAL" \+ "iCloud.$EXPECTED_TOKEN_DEVELOPMENT" \+ "iCloud.$EXPECTED_TOKEN_PERSONAL"+ do+ sweep_for "$value"+ done+}++# An extended-regex pattern matching a composed value only as a whole identifier.+#+# `grep -w` is NOT good enough here, and this is the trap the whole file warns+# about: grep's word characters are [A-Za-z0-9_], so `.` counts as a boundary and+# `-w group.me.nore.ig.Asterism` happily matches inside+# group.me.nore.ig.Asterism.dev. Every Personal value is a strict prefix of its+# Development sibling, so that would report the Personal literal on every line+# carrying the Development one. The class below therefore includes `.` and `-`.+identifier_pattern() {+ local escaped+ escaped="$(printf '%s' "$1" | sed 's/[.[\*^$()+?{|]/\\&/g')"+ printf '(^|[^A-Za-z0-9_.-])%s([^A-Za-z0-9_.-]|$)' "$escaped"+}++sweep_for() {+ local value="$1" line path hits="$WORK_DIR/sweep-hits" status=0+ (cd "$REPO_ROOT" && git grep -n -E -e "$(identifier_pattern "$value")" -- \+ '*.swift' '*.plist' '*.entitlements' '*.pbxproj' 'Makefile') \+ >"$hits" 2>"$hits.err" || status=$?+ if ! git_grep_status_ok "$status" "the composed literal '$value'" "$hits.err"; then+ return+ fi+ while IFS= read -r line; do+ [ -n "$line" ] || continue+ # `git grep -n` prints path:line:content, so the path is the first field.+ # The sentinel only exempts the Makefile banner (Q8); honoured everywhere,+ # it would be a way for any file to opt out of the sweep.+ path="${line%%:*}"+ if [ "$path" = "$SWEEP_SENTINEL_FILE" ]; then+ case "$line" in+ *"$SWEEP_SENTINEL"*) continue ;;+ esac+ fi+ fail "the composed literal '$value' appears outside the declaration: $line"+ done <"$hits"+}++# The banner's exempt line must still say the right thing (Q8): the exemption+# exists so the warning stays readable, not so it can rot.+check_banner() {+ local makefile="$REPO_ROOT/$SWEEP_SENTINEL_FILE" expected="group.$EXPECTED_TOKEN_PERSONAL"+ local matches+ matches="$(grep -c -F -e "$SWEEP_SENTINEL" "$makefile" 2>/dev/null || true)"+ if [ "${matches:-0}" -ne 1 ]; then+ fail "expected exactly 1 '$SWEEP_SENTINEL' line in the Makefile, found ${matches:-0}"+ return+ fi+ if ! grep -F -e "$SWEEP_SENTINEL" "$makefile" | grep -q -E -e "$(identifier_pattern "$expected")"; then+ fail "the Makefile device-warning banner no longer names '$expected'; a wrong App Group in that warning is worse than none (Q8)"+ fi+}++# ------------------------------------------------------------------------------++check_project_level_settings+check_no_shadow_assignments+check_entitlements+check_info_plists+check_literal_sweep+check_banner++if [ "$failures" -gt 0 ]; then+ printf 'verify-identity: %d problem(s) found.\n' "$failures" >&2+ exit 1+fi++note "declarations agree: Development $EXPECTED_TOKEN_DEVELOPMENT, Personal $EXPECTED_TOKEN_PERSONAL"
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex f8ea1c0..a8e518b 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -9,7 +9,7 @@ | [Library Integrity Tolerance](#library-integrity-tolerance) | 2026-07-25 | Done — **one requirement unmet** | Makes three recoverable graph states degrade instead of failing the library, ahead of enabling CloudKit. Req 5.5's 250 ms diagnosis budget measures 0.268–0.278 s on host and ships as a known issue (Decision 11). | | [Relational References](#relational-references) | 2026-07-26 | Done — **one requirement unmet** | Converts the hostname and cited-rule string references into modelled relationships, so an unarrived target is nil and heals itself. Scheduled ahead of mirroring because a migration is a one-device problem only until sync is on. Req 2.6's 10 s migration budget measures 17.31–17.75 s on host over the single-Site 5,000-Entry fixture and ships as an accepted known issue (Decision 6, Q60). | | [CloudKit Mirroring](#cloudkit-mirroring) | 2026-07-26 | Planned | Enables mirroring on separate containers per configuration, app-only; reconciles duplicate Site rows; makes export always produce a file and import upsert-only. No archive format change. |-| [Configuration Identity](#configuration-identity) | 2026-07-29 | Planned | Single-sources the App Group and CloudKit container identifiers: one identity token per configuration, everything derived, divergence fails the build or the lint. Deletes `LibraryEnvironment`. Prerequisite for the CloudKit Mirroring Development flip (T-1982). |+| [Configuration Identity](#configuration-identity) | 2026-07-29 | Done | Single-sources the App Group and CloudKit container identifiers: one identity token per configuration, everything derived, divergence fails the build or the lint. Deletes `LibraryEnvironment`. Prerequisite for the CloudKit Mirroring Development flip (T-1982). | --- @@ -124,5 +124,6 @@ Single-sources the identifiers that define which library and which cloud contain - [decision_log.md](configuration-identity/decision_log.md) - [design.md](configuration-identity/design.md)+- [implementation.md](configuration-identity/implementation.md) - [requirements.md](configuration-identity/requirements.md) - [tasks.md](configuration-identity/tasks.md)
diff --git a/specs/configuration-identity/decision_log.md b/specs/configuration-identity/decision_log.mdindex d7b1e87..878f445 100644--- a/specs/configuration-identity/decision_log.md+++ b/specs/configuration-identity/decision_log.md@@ -25,6 +25,12 @@ | Q19 | 2026-07-29 | `#Preview { ContentView() }` switches to the explicit-configuration initializer | The preview agent's `Bundle.main` is not the app bundle and no build check governs it, so production resolution there could hit the Req 2.3 fatalError as a false positive; injecting a temp-root configuration keeps previews out of the identity path entirely | | Q20 | 2026-07-29 | The build script recomputes expected values from the token (`group.${ASTERISM_IDENTITY}`) rather than trusting the derived settings | Comparing the entitlements only against the derived settings verifies internal consistency, not the declaration — a derivation typo expanding an undefined variable passes every such check; recomputing makes derivation breaks fire in-build, including in Personal, which the lint bar never gates | | Q21 | 2026-07-29 | The simulator test asserts both Info.plist keys against the hosted app's real bundle | The container key otherwise has no end-to-end proof (Non-Goal 1 bars a package accessor, not a test assertion), and the app target merges `GENERATE_INFOPLIST_FILE = YES` with an explicit `INFOPLIST_FILE` — no custom key from that file is read at runtime today, so the merge preserving custom keys was an unproven assumption |+| Q22 | 2026-07-30 | Build check 2's array checks run unscoped on both platforms, and stay key-scoped rather than comparing whole files | Task 1 measured both identity arrays present on simulator and device (`docs/investigations/configuration-identity-xcent.md`), closing the design's open scoping question; device signing also injects `team-identifier`/`get-task-allow` and drops the macOS-only aps key, so any whole-file comparison fails on a correct build |+| Q23 | 2026-07-30 | The build script asserts `ASTERISM_XCENT_SUFFIX` matches the platform instead of trusting `.xcent` file presence | The design's "only one exists per platform" premise is wrong: simulator builds produce both files and the unsuffixed one is an empty dict, with `-Simulated.xcent` carrying the real content — so a presence check on the plain file passes vacuously, and a dropped SDK condition must fail loudly rather than silently redirect the check to the empty file |+| Q24 | 2026-07-30 | Single-element proof extracts the array's element count; entitlement-key dots are escaped in every `plutil` keypath | `plutil -extract '<key>' raw` on an array prints its count — one call, better diagnostic than the design's probe-index-1-must-fail form (which still holds, and is kept as the element read at `.0`); unescaped keypaths split on the dots that fill entitlement keys and resolve to nothing, making the checks vacuous or false-alarming |+| Q25 | 2026-07-30 | The lint's literal matching uses an explicit character class including `.` and `-`, not `grep -w` | grep's word characters are `[A-Za-z0-9_]`, so `.` counts as a boundary and `-w group.me.nore.ig.Asterism` matches inside `group.me.nore.ig.Asterism.dev` — the exact strict-prefix collision the boundary requirement exists to prevent; both behaviours verified against a fixture |+| Q26 | 2026-07-30 | The Req 4.4 demonstration table lives in `implementation.md`, not appended to `tasks.md` (supersedes Q14's location and Landing Order step 6) | rune owns the tasks file format and cannot parse trailing free-form content; result tables do not belong in a rune-managed file |+| Q27 | 2026-07-30 | Both verification layers assert the extension carries neither `icloud-container-identifiers` nor `icloud-services` | Non-Goal 3 bars any iCloud entitlement on the extension, but the original checks only asserted the container key absent — `icloud-services` alone would have passed every check (pre-push review finding) | ## Decision 1: The Package Receives Identifiers by Injection, Not by Reading Its Environment
diff --git a/specs/configuration-identity/implementation.md b/specs/configuration-identity/implementation.mdnew file mode 100644index 0000000..130c630--- /dev/null+++ b/specs/configuration-identity/implementation.md@@ -0,0 +1,31 @@+# Implementation Notes: Configuration Identity++## Req 4.4 demonstration++Eight deliberate breaks, one per check, each run against the layer that owns it —+`make verify-identity` for the lint, `make build` (Development, iOS Simulator) for+the in-build phase. Every break was reverted with `git checkout` immediately after+its diagnostic was captured, and `git status` was verified clean between breaks and+at the end. Diagnostics are verbatim; absolute paths are elided to `.../`.++Two notes on break choices. Break 3 uses the literal `group.me.nore.ig.Asterism.development`+rather than a real composed value so that the entitlements-reference check fires in+isolation — a real one would also trip the literal sweep, and the row is meant to+demonstrate one check. Break 7 is the Q6 catastrophic case: the build *signs+successfully* with both configurations' App Groups, so only exact single-element+equality catches it.++| # | Check | Layer | Deliberate break | Diagnostic observed |+|---|---|---|---|---|+| 1 | Token pin (lint check 1) | `make verify-identity` | Project-level `ASTERISM_IDENTITY = me.nore.ig.Asterism.develop` in the Development configuration | `verify-identity: error: ASTERISM_IDENTITY for Development is 'me.nore.ig.Asterism.develop', expected 'me.nore.ig.Asterism.dev' (pinned; change both the declaration and the pin deliberately)` |+| 2 | Derivation text (lint check 2) | `make verify-identity` | Project-level `ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTIY)"` — one-letter typo in the reference | `verify-identity: error: ASTERISM_APP_GROUP_IDENTIFIER for Development is 'group.$(ASTERISM_IDENTIY)', expected 'group.$(ASTERISM_IDENTITY)'` |+| 3 | Entitlements literal (lint check 3) | `make verify-identity` | `Asterism.entitlements` app-groups element replaced by the literal `group.me.nore.ig.Asterism.development` | `verify-identity: error: .../Asterism/Asterism/Asterism.entitlements declares com.apple.security.application-groups as 'group.me.nore.ig.Asterism.development', expected the reference '$(ASTERISM_APP_GROUP_IDENTIFIER)' — a literal here can drift from the declaration` |+| 4 | Duplicate target-level setting (lint shadow check) | `make verify-identity` | `ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTITY)"` added to the app target's Development `XCBuildConfiguration` — same text as the project declaration, so only ownership distinguishes it | `verify-identity: error: ASTERISM_APP_GROUP_IDENTIFIER is assigned in the Development configuration of target Asterism; identity settings belong to the project only — a target-level assignment shadows the declaration and Xcode prefers the shadow` |+| 5 | Info.plist key (build check 3) | `make build` | `AsterismCloudKitContainerIdentifier` deleted from `Asterism/Asterism/Info.plist` | `error: verify-build-identity [Asterism/Development]: .../Asterism/Asterism/Info.plist has no AsterismCloudKitContainerIdentifier; the built bundle is where the running process reads it from` — build failed (exit 65); the extension's phase passed in the same build |+| 6 | Swept literal (lint check 5) | `make verify-identity` | Comment `// Fallback App Group: group.me.nore.ig.Asterism.dev` appended to `ContentView.swift` | `verify-identity: error: the composed literal 'group.me.nore.ig.Asterism.dev' appears outside the declaration: Asterism/Asterism/ContentView.swift:285:// Fallback App Group: group.me.nore.ig.Asterism.dev` |+| 7 | Entitlements array element, Q6 both-groups (build check 2) | `make build` | `group.me.nore.ig.Asterism` added as a second element of the app's `com.apple.security.application-groups` array | `error: verify-build-identity [Asterism/Development]: com.apple.security.application-groups in .../Asterism.app-Simulated.xcent holds 2 entries, expected exactly 1 ('group.me.nore.ig.Asterism.dev'); more than one means this product can reach another configuration's data` — build failed (exit 65) |+| 8 | Banner drift (lint banner check) | `make verify-identity` | The Makefile device-warning banner's sentinel line changed to `group.me.nore.ig.Asterism.dev` (the sentinel exempts it from the sweep, so only the banner check can catch this) | `verify-identity: error: the Makefile device-warning banner no longer names 'group.me.nore.ig.Asterism'; a wrong App Group in that warning is worse than none (Q8)` |++Each lint break failed `make verify-identity` with `verify-identity: 1 problem(s) found.`;+each build break failed the build with the `error:`-prefixed line above, which Xcode+surfaces as a build error rather than log noise.
diff --git a/specs/configuration-identity/tasks.md b/specs/configuration-identity/tasks.mdindex 5515e5c..60236b6 100644--- a/specs/configuration-identity/tasks.md+++ b/specs/configuration-identity/tasks.md@@ -8,7 +8,7 @@ references: ## Groundwork -- [ ] 1. Measure processed entitlements (.xcent) for both platforms and record in docs/investigations/ <!-- id:vjmyza1 -->+- [x] 1. Measure processed entitlements (.xcent) for both platforms and record in docs/investigations/ <!-- id:vjmyza1 --> - Build app + extension for iphonesimulator and for generic/platform=iOS (device BUILD only — nothing is installed or run on hardware); inspect $(TARGET_TEMP_DIR)/$(FULL_PRODUCT_NAME).xcent and the -Simulated variant - Record which entitlement keys each platform actually carries — no artifact on this machine post-dates the iCloud entitlement keys, and build check 2's scope depends on this (design Verification) - Write findings to docs/investigations/configuration-identity-xcent.md@@ -17,7 +17,7 @@ references: ## Declarations -- [ ] 2. Single-source the identity declarations across pbxproj, entitlements, and Info.plists <!-- id:vjmyza2 -->+- [x] 2. Single-source the identity declarations across pbxproj, entitlements, and Info.plists <!-- id:vjmyza2 --> - pbxproj: ASTERISM_IDENTITY + the two derived settings in the two project-level XCBuildConfiguration blocks only; delete the four target-level ASTERISM_APP_GROUP_IDENTIFIER (:398,427,456,504) and two ASTERISM_STORE_RELATIVE_PATH (:457,505) - Entitlements: collapse four files to Asterism.entitlements + AsterismShareExtension.entitlements with $(...) references; update four CODE_SIGN_ENTITLEMENTS entries; drop the macOS-only com.apple.developer.aps-environment key (Q18) - Info.plists: app adds AsterismCloudKitContainerIdentifier and deletes AsterismStoreRelativePath; extension adds AsterismAppGroupIdentifier (hand-edit — GENERATE_INFOPLIST_FILE=NO)@@ -27,23 +27,23 @@ references: ## Swift -- [ ] 3. Write failing package tests for the identity reader and explicit-identifier production() <!-- id:vjmyza3 -->+- [x] 3. Write failing package tests for the identity reader and explicit-identifier production() <!-- id:vjmyza3 --> - ConfigurationShellTests: declaredAppGroupIdentifier(fromInfoDictionary:) — valid value; missing key; empty string; unresolved $( value — each throws naming AsterismAppGroupIdentifier - production(appGroupIdentifier:locator:) with fixture identifiers replacing the environment-based cases at :33,45,59 - Red = failures against the not-yet-implemented API; do not modify package sources in this task - Stream: 1 - Requirements: [3.1](requirements.md#3.1) -- [ ] 4. Implement the package identity API and delete LibraryEnvironment across the package <!-- id:vjmyza4 -->+- [x] 4. Implement the package identity API and delete LibraryEnvironment across the package <!-- id:vjmyza4 --> - LibraryConfiguration: add declaredAppGroupIdentifier(fromInfoDictionary:/in:) + appGroupInfoPlistKey; production(appGroupIdentifier:locator:); delete LibraryEnvironment, the environment property, and the init parameter (Decision 3) - Package ripple: AsterismMigrationTool (--root required, --environment removed, Q15); AsterismStoreTestHelper subcommands drop the environment argument along with their RepositoryBootstrapTests invocations (:94,121-122); M3ScaleFixture.make loses its parameter - Delete: M3ScaleFixtureTests environmentIsolation + repositorySeederPersonalEnvironment, RepositoryBootstrapTests:62, ConfigurationShellTests environmentsAreIsolated + currentEnvironmentResolvesDevelopment (lint check 1 owns these invariants now) - Green bar: make test-core passes (swift test --no-parallel if run by hand)- - Blocked-by: vjmyza3 (Write failing package tests for the identity reader and explicit-identifier production()), failing, package+ - Blocked-by: vjmyza3 (Write failing package tests for the identity reader and explicit-identifier production()) - Stream: 1 - Requirements: [2.2](requirements.md#2.2), [2.5](requirements.md#2.5), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3) -- [ ] 5. Rewire the app, share extension, app tests, and migrate-m1-to-m2 to injected identifiers <!-- id:vjmyza5 -->+- [x] 5. Rewire the app, share extension, app tests, and migrate-m1-to-m2 to injected identifiers <!-- id:vjmyza5 --> - Per the design call-site audit: AppLibraryModel (String? property, production init, four nil-setting inits, guard message), ContentView both branches with fatalError naming the key + #Preview switched to an explicit configuration (Q19), ShareViewController inside its existing do/catch, UITestLaunchSupport:146 - App tests: AppLibraryModelTests fixture identifiers + delete configurationIsolation; UITestLaunchSupportTests :37,78,105; IntegrationSafetyNetTests:169 loop over plain label strings; AsterismTests:14 - New AsterismTests case asserting Bundle.main carries both identity keys with group./iCloud. prefixes and .dev suffix (Req 2.4's end-to-end proof, Q21)@@ -55,7 +55,7 @@ references: ## Verification -- [ ] 6. Add the in-build entitlements verification phase to both targets <!-- id:vjmyza6 -->+- [x] 6. Add the in-build entitlements verification phase to both targets <!-- id:vjmyza6 --> - scripts/verify-build-identity.sh per the design's Verification section: recompute expected values from ASTERISM_IDENTITY (Q20); exact single-element array equality via plutil -extract raw (index 1 must fail to extract); hard-fail on a missing .xcent; platform branch on PLATFORM_NAME ending in simulator; assert INFOPLIST_EXPAND_BUILD_SETTINGS is not NO; error:-prefixed diagnostics - pbxproj: ASTERISM_XCENT_SUFFIX SDK-conditional setting; hand-added PBXShellScriptBuildPhase in both targets — new UUIDs, alwaysOutOfDate=1, declared inputs are the one .xcent path, the source Info.plist, and the script file itself (scripts/ is outside SRCROOT) - Scope of the array checks follows task 1's recorded findings@@ -64,7 +64,7 @@ references: - Stream: 2 - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2) -- [ ] 7. Add the identity lint and wire it into the Makefile as a test-core prerequisite <!-- id:vjmyza7 -->+- [x] 7. Add the identity lint and wire it into the Makefile as a test-core prerequisite <!-- id:vjmyza7 --> - scripts/verify-identity.sh per the design's Verification section: pinned tokens me.nore.ig.Asterism.dev / me.nore.ig.Asterism (Q16); pbxproj parsed via plutil -convert json for project-level ownership; boundary-aware matching (every Personal value is a strict prefix of its Development sibling); git ls-files-scoped literal sweep; sentinel comment "# identity-lint: allowed" added to the Makefile banner's group. line - Makefile: verify-identity target; test-core gains it as a prerequisite (Q13) - Verify: make verify-identity clean on the final tree; make test-core green end-to-end@@ -72,9 +72,9 @@ references: - Stream: 1 - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [4.3](requirements.md#4.3) -- [ ] 8. Run the eight-break Req 4.4 demonstration and the full simulator suites <!-- id:vjmyza8 -->+- [x] 8. Run the eight-break Req 4.4 demonstration and the full simulator suites <!-- id:vjmyza8 --> - One deliberate break per check, each shown to produce its diagnostic, then reverted: token pin, derivation text, entitlements literal, duplicate target-level setting, Info.plist key, swept literal, entitlements array element (the Q6 both-groups case), banner drift- - Record the eight rows (break → diagnostic observed) as a table appended to this tasks.md+ - Record the eight rows (break → diagnostic observed) — recorded in [implementation.md](implementation.md) - Final bar: make test-core, make test-quick, make test, and make build-release all green - Blocked-by: vjmyza6 (Add the in-build entitlements verification phase to both targets), vjmyza7 (Add the identity lint and wire it into the Makefile as a test-core prerequisite) - Stream: 1
The two PBXShellScriptBuildPhase objects were hand-added with sequential UUIDs (A1…16/A1…17). The next GUI edit of the project may rewrite formatting or reorder keys — the phases survive, but expect pbxproj churn in an unrelated commit. plutil -lint passes today.
The collapse removed the macOS-only com.apple.developer.aps-environment key. Measurement showed device signing drops it anyway and the iOS aps-environment value comes from the provisioning profile at export — but if a Mac Catalyst target ever appears, this key's absence is the place to look.
Changing either configuration's identity requires editing both the pbxproj declaration and the pins in scripts/verify-identity.sh (Q16) — this is deliberate friction. The diagnostic says so, but it will surprise the first person who tries a rename.