Skip to content

[CHORE] Land the v1 head that #396 left behind - #418

Open
justin13888 wants to merge 77 commits into
masterfrom
chore/merge-v1-head-397
Open

[CHORE] Land the v1 head that #396 left behind#418
justin13888 wants to merge 77 commits into
masterfrom
chore/merge-v1-head-397

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

PR #396 merged a stale local copy of feat/v1-code-push (9cc89ac2). The 75 commits that had already landed on origin/feat/v1-code-push — the Apple client (#388), the docs-truth gates and ADRs (#395), the wasm32-unknown-unknown CI fix, and the i18n-guard computed-property detector that #394 describes — never reached master, which is why master's CI is red and #390#394 cite code and slices master does not have.

This is one merge commit of origin/feat/v1-code-push into master. No rebase, no history rewritten.

The single conflict, in the root Cargo.toml, resolves to the v1-head side: it removes a strict superset of the unused [workspace.dependencies] that master removed (data-encoding, derive_more, futures-util, file-format, num-rational, tower-http); no manifest outside legacy-review/ references any of them, and Cargo.lock is unchanged by the resolution.

Related Issues

Closes #397

Decisions taken

  1. Conflict resolution in Cargo.toml.
    Taken: the v1-head side — six unused workspace dependencies removed; Cargo.lock unchanged.
    Rejected: keeping master's side — it retains entries no member consumes, and the v1 head's xtask workspace-dependency check (21f875de) is what removed them.
    Reverses: re-add the six lines under [workspace.dependencies].

  2. Base for every follow-on pull request in this programme.
    Taken: this branch's head, until it merges (user decision, taken before the run started).
    Rejected: targeting feat/v1-code-push directly; building on master as-is (would conflict with the 707 files here).
    Reverses: retarget each follow-on pull request to master after this one merges.

Validation

Run on the merged tree at f433d918, on the development host (16 cores; other worktrees compiling concurrently):

  • mise run check-rust — passed (format, clippy, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples).
  • mise run test-rust — passed: cargo nextest run --workspace 1703/1703; -p capsule-core --features ffi 734/734; -p capsule-sdk --features ffi 160/160; 0 skipped.
  • mise run check-docs-truth — passed: cross-links 473 resolve, endpoint-census 84 resolve, module-paths 119 resolve.
  • Not run here: check-web, check-docs, check-kotlin, check-swift (unchanged toolchains relative to the v1 head; CI runs them).

Post-open repair

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

Unresolved review notes

  • Medium · 99dd4bc8 commit message · the stated cause of the lockfile drift is inverted (blames the v1 head; the drift is master-side). Not repaired in code: correcting it needs a history rewrite of a pushed commit, which this run does not perform; the corrected cause is recorded above and in the run report.

The pinned swiftformat install on the dev host was a corrupt app-bundle
extraction whose Info.plist no longer matched its signature, so the hardened
runtime SIGKILLed it (exit 137) on every invocation. Reinstalling it fixed
that — and revealed that `mise run format-swift` had therefore never actually
run against this tree, neither locally (the hook silently died) nor in CI
(`build-ios.yml` runs no linters and `ci.yml` has no Swift job at all).

Running it surfaced a config contradiction the two gates could never both
satisfy: swiftformat's `wrapMultilineStatementBraces` moves an opening brace
onto its own line whenever the condition wraps, while swiftlint's
`opening_brace` rule rejects exactly that. Any file with a wrapped `if let`
failed one gate or the other no matter how it was written.

Disable the swiftformat rule — swiftlint wins because its style matches the
Swift API Design Guidelines and the rest of this codebase — and apply the
formatter to the tree it had never touched.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
The app could not build without Rust. `CapsuleCatalog` compiled the generated
uniffi glue and linked `.ffi/CapsuleCoreFFI.xcframework`, so `tuist generate &&
xcodebuild` required a five-target Rust cross-compile first — the dominant cost
of any local verification, and a hard prerequisite for work that never touches
the core at all.

Split the module along the seam that was already there:

- `CapsuleCatalog` keeps the contract, the Swift-native models, a native
  `CatalogError` (a parity mirror of the uniffi enum), and
  `InMemoryAssetCatalog` — the faithful reference implementation promoted out
  of `CapsuleTestSupport`, because the mock-lane app and SwiftUI previews need
  it too, not just tests. No Rust.
- `CapsuleCatalogFFI` takes `FFIAssetCatalog`, the record conversions, and the
  new error mapping. It is gated behind `TUIST_FFI=1` and is absent from the
  default graph; nothing above it can name a generated type.

Two hard-wired dependencies became injected seams so `ManagedStore` compiles
either way: `CatalogOpening` (how the catalog is opened) and `SidecarCoding`
(the sidecar wire format — canonical CBOR through the core, JSON otherwise).
`FFIAssetCatalog` now maps every uniffi error onto the native `CatalogError` at
its boundary rather than letting generated types escape.

`tuist generate && xcodebuild test` now runs from a clean checkout with no Rust
toolchain: 93 tests across 28 suites pass on the iOS simulator.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
The app was iPhone/iPad only, on an iOS 18 floor, with UIKit reached for
directly from twenty files across nine modules. This makes macOS a native
destination — not Catalyst — and raises the floor to iOS/iPadOS 26 and macOS 26.

The floor is the enabling decision: at 26 the Liquid Glass APIs,
`.tabBarMinimizeBehavior`, and `.navigationTransition(.zoom)` are available
unconditionally, so `CapsuleGlass` sheds every `#available` fence and its
pre-26 material fallbacks, and there is one visual language to design and audit
rather than two.

Platform differences are confined, named, and enforced:

- `CapsuleFoundation/Sources/Platform/` holds the type shim — `PlatformImage`,
  `PlatformColor`, `Image(platformImage:)`, plus `PlatformEnvironment` and
  `PlatformLifecycle`, which expose *capabilities* (`hasMenuBar`,
  `libraryIsSandboxPrivate`, a memory-warning notification that is `nil` where
  the platform has none) rather than an OS name to switch on.
- `CapsuleUI/Sources/PlatformCollection/` is the collection-view island every
  grid is built on: one SwiftUI-facing wrapper over `UICollectionView` and
  `NSCollectionView`, sharing the compositional-layout maths and the diffable
  snapshot logic, with cells hosting ordinary SwiftUI. The three
  `UICollectionViewCell` subclasses became SwiftUI views written once, and
  `ZoomableImageView` became pure SwiftUI gestures.
- A `no_platform_ui_import` SwiftLint rule fails any `import UIKit`/`AppKit`
  outside a `Platform/` directory. A view body that needs a platform difference
  gets a named modifier, not an inline `#if`.

Some differences are real and are handled rather than hidden: the Mac viewer
pages with arrow keys and chevrons instead of a swipe pager, import uses an open
panel (which yields real filenames), the search field sits in the window
toolbar, and `UIActivityViewController` is replaced by `ShareLink` on both
platforms — which also made sharing decode lazily, so selecting 200 photos no
longer loads 200 images before the sheet opens.

Also here: the app target is now one shared `App/` tree; the Mac gets a real
menu bar and a `Settings` scene behind ⌘,; and an XCUITest bundle runs Apple's
`performAccessibilityAudit` across six mock scenarios — the automatable half of
"Apple design compliant".

One test was reaching the real Photos library from a unit-test bundle, which is
a TCC violation that hard-fails the process on macOS. It now uses a mock, which
is what it should have done anyway — the claim under test never needed a system
photo library.

Verified: build and unit tests pass on macOS, iOS Simulator (iPhone 17 Pro), and
iPadOS Simulator (iPad Pro 13-inch M5).

BREAKING CHANGE: the Apple client's deployment floor is now iOS/iPadOS 26 and
macOS 26, raised from iOS 18.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
macOS needs strings iOS never did: the viewer has no swipe pager there, so it
grows two chevron buttons that need accessibility labels, and the menu bar needs
names for the commands it exposes. The diagnostics share sheet also needed a
title distinct from the button that opens it — the button ends in an ellipsis,
which reads wrong as a heading.

Six keys, English only. Translations may lag: every locale falls back to the
source catalog per `locales/config.json`, so a missing key renders in English
rather than as a raw key.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Swift was the only toolchain with no `check-<toolchain>` task and no CI job.
`ci.yml` had no Swift filter and no Swift job at all, so its aggregated
`required` check — the one branch protection gates on — could pass with the
Apple client broken. `build-ios.yml` built one iPhone simulator and ran no
linters, and Swift format/lint were enforced only by local hooks, which is to
say not at all for anyone who skips them or is not on macOS.

- `mise run check-swift` joins the per-toolchain convention: format-check +
  lint-check + `test-swift`, which runs the unit suites on macOS, an iPhone
  simulator, and an iPad simulator.
- A `swift` job in `ci.yml` runs it on `macos-26`, gated by a new path filter,
  and is now in `required`'s needs. An iOS or macOS break can no longer merge.
- `build-swift` and `setup-swift` default to the mock lane, so neither needs a
  Rust toolchain; `setup-swift-ffi` is the Rust-backed path.
- `build-ios.yml` becomes the Rust-backed lane explicitly: it generates with
  `TUIST_FFI=1` and additionally builds for macOS, because the macOS staticlib
  slice is the one most likely to rot unnoticed.
- `build-rust-ffi.sh` gained `aarch64-apple-darwin` and `x86_64-apple-darwin`
  slices — without them the xcframework has nothing for the Mac to link.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`SLICES.md` recorded two blockers that no longer hold, and one of them was never
quite right. Both are now resolved on the dev host, so lane P does not have to
ride CI.

swiftformat was recorded as "broken on dev host — binary SIGKILLed (invalid
signature)". It was a corrupt app-bundle extraction whose `Info.plist` no longer
matched its signature; reinstalling gives a working binary. That it had never
run is why the swiftformat/swiftlint brace conflict went unnoticed for so long.

Xcode was recorded as failing `-create-xcframework` with exit 70 after
`CoreSimulator.framework` was removed, needing `sudo xcodebuild -runFirstLaunch`.
`CoreSimulator.framework` is present and `-create-xcframework` exits 0. The
actual residual failure was different: Xcode 26.6 ships the iOS 26.5 SDK but the
host carried only 26.0/26.3 runtimes, so `xcodebuild` enumerated no iOS
simulator destinations at all — `-showdestinations` listed only ineligible
device entries. `xcodebuild -downloadPlatform iOS` fixed it, with no sudo.

The README is rewritten around what the app now is: three platforms, two build
lanes, the platform rule, and how to run it on the Mac.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
A photo grid that materializes its sections cannot scroll a real library. The
docs position Capsule at "terabytes of visual assets", the catalog is paged
(`offset`/`limit`) by construction, and a `LazyVGrid` over a `ForEach` needs the
whole identity array up front — so its diffing cost grows with the library, and
it cannot know total content height without loading everything. A scrollbar that
lies and a scroll position that jumps are the visible symptoms.

`TimelineLayout` computes the geometry from section *counts* alone. Its input is
one `(dayKey, count)` row per day — a few thousand rows for a decade, which the
local index answers with a `GROUP BY` rather than a scan — and from that it
precomputes prefix sums of item counts and vertical offsets. Three things then
become cheap that are otherwise impossible:

- total content height is exact before a single asset loads;
- "which items intersect the viewport?" is a binary search over sections, not a
  walk over items;
- seeking to a date is O(log n), so a fast-scroll scrubber can jump to October
  2019 in a 250 000-asset library instantly.

It is a pure value type — no view, no store, no platform dependency — so the
maths is directly testable, and the same engine can back every grid in the app
by swapping the aggregate query.

Tested at both scales: exact height, tiling order, section lookup, scrubber keys,
and nearest-item resolution (which is what keeps your place across a zoom-level
change); plus a suite over 3 650 sections and 250 000 items asserting that
construction and a thousand viewport queries each stay in the low milliseconds,
and that a viewport resolves to tens of items rather than the whole library.

Also fixes two main-actor isolation warnings in `AnyCollectionLink` that would
become errors under a stricter language mode, and wires up `CapsuleUI`'s unit
test target, which the module factory had never been asked to create — so
`CapsuleUI`'s tests, including the grid metrics and zoom maths, were inert.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Both failures predate this branch and neither is caused by the Apple client
work, but both sit in `required`, so nothing can go green until they are fixed.

**protoc.** `capsule-sdk`'s build script compiles the `capsule.sync.v1` proto
with prost-build, which shells out to `protoc`. The `rust`, `rust-test`,
android, and release jobs already install it via `arduino/setup-protoc`; the
`rust-cross` matrix and `build-ios` never did, so both died in the SDK build
script before reaching anything they were meant to test. This just applies the
convention already used everywhere else in the workflow.

**ktlint.** Three violations in `capsule-core-kotlin`'s `StrongBoxSigner.kt`
failed `:core:ktlintMainSourceSetCheck`: a multiline expression that had to
start on its own line, and a KDoc block written with `///`, which is not Kotlin
doc syntax — ktlint reads the third slash as a comment body with no leading
space. Rewritten as a `/** */` block, which is also what actually renders.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
The UI is being built against mocks because `capsule-core` and `capsule-sdk` are
mid-rebuild. These two modules are the seam that makes that honest rather than
throwaway: `CapsuleDomain` holds the value types, `CapsulePorts` the async
protocols, and feature modules import only these for data. Swapping the mock
adapters for the real uniffi ones becomes a change in the composition root with
no view, view-model, or navigation code touched.

Every type is shaped as a structural mirror of the Rust type it will be
generated from, with the doc comment naming its owner design doc: the full
`SidecarV1` field set with its CRDT registers, the provenance manifests and the
13 `verify_asset` reject reasons, the representation/degrade ladder, the closed
predicate grammar with its depth-8 / 64-term validator, the five quota states,
the eight quarantine surfaces from the threat model's own table, staged upload
tiers, device cohorts, share links and drops, federation and peering.

Six FFI parity rules are encoded and tested, not just documented. The load-
bearing one is `ClosedWireEnum`: closed enums cross the boundary as strings, so
each carries an `unknown(String)` case — reading an unrecognised value is legal
and renders the "created with a newer version" indicator, while writing one is a
structural rejection via `requireWritable()`. Swift forbids a raw-value enum
from carrying an associated value, so the conformance is hand-rolled.

Three defects the tests caught before any UI depended on them:

- **`Hashable` and `Equatable` disagreed on every wire enum.** The stdlib gives
  `RawRepresentable` an `==` that compares raw values, but `hash(into:)` was
  synthesised structurally — so `.unknown("pick")` equalled `.pick` with a
  different hash. That silently breaks every `Set` and every dictionary keyed on
  a wire enum, including the query-grammar lookup tables.
- **`Lww.merging` was not idempotent**, duplicating superseded entries on every
  self-merge, which violates the grouping-convergence requirement.
- **The timeline query modelled trash and hidden as include-toggles**, so the
  Trash view would have returned the whole library with trash mixed in. Replaced
  with a closed `VisibilitySlice`, orthogonal to stack-hidden.

Two naming deviations from the design docs, both forced: `Predicate` collides
with `Foundation.Predicate` and `Operator` is a Swift keyword, so they are
`SmartAlbumPredicate` and `PredicateOperator`. Wire casing is mirrored per type
rather than globally snake_case, because the Rust `serde` attributes disagree
with each other and one wrong character is a failed signature.

280 tests pass on macOS, iOS, and iPadOS.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`values-zh-rHANS` is not a valid Android resource directory. `aapt2` rejects it
with "Invalid resource directory name" and fails the *entire* Android build, not
just that locale — which is why `:android:packageDebugResources` was failing.

The mapping treated every subtag after the language as a region and forced it
into the legacy `-rREGION` qualifier. But `Hans` is a *script*, and Android has
no legacy spelling for one: a script can only be named in the BCP-47 form,
`values-b+zh+Hans`. Regions are two letters or three digits and scripts are four
letters, so the two are separable by length alone.

`pt-BR` keeps its correct `values-pt-rBR`; only the two Chinese locales move.

This predates the branch — it arrived with the twelve-locale rollout and lay
dormant because `build-android.yml` is path-filtered and nothing had touched
`capsule-android/**` since. Regenerating the catalogs for the Apple client
touched `values/strings.xml`, which triggered the workflow and surfaced it.

Also adds `wasm32-unknown-unknown` to the Rust job's toolchain step. `check-rust`
includes `build-check-wasm`, which type-checks the wasm32 sealing surface;
without the target, cargo cannot find `core` and the gate dies on the first
dependency it compiles.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
The app has around eighty screens across three shells — a tab bar on iPhone, a
three-column split view on iPad and Mac, plus detached windows and a menu bar on
Mac. Without a shared vocabulary each shell grows its own navigation, and deep
links, menu commands, and notifications each need three implementations.

`CapsuleNavigation` is that vocabulary: a 39-case `Route`, a `SidebarItem`
catalog of 19 sections, and an `@Observable` `Router` holding one
`NavigationStack` path *per section* — so switching sections and coming back
preserves each section's own history, which is the behaviour a tab bar implies
and the thing most often got wrong. It contains no SwiftUI views, which is
precisely what lets three different shells bind it.

Deep-link parsing is total: an unknown or malformed URL returns `nil` rather
than guessing. Share and upload links (`/s/<id>#<secret>`, `/u/<id>#<key>`) carry
a `LinkSecret` that redacts itself in `description` *and* `debugDescription`, is
not `Codable`, and is returned from `open(_:)` rather than stored — so the
fragment never reaches observable or persisted state, and nothing logs it.

Five deliberate deviations from the brief, each forced by the domain:

- `Route.linkRedemption` is separate from `shareDetail(ShareID)`. A `/s/` link's
  opaque id and a `ShareID` are different id spaces — the latter is the owner's
  revocation handle — and conflating them would have crossed them.
- `Route.albums` was added: every other index/detail pair had an index, so the
  Albums section otherwise had no landing screen.
- `search` carries the query text, because `capsule://search?q=` has nowhere
  else to put it.
- Zoom is `zoom(TimelineFocus)` rather than an integer, so ⌘1–⌘4 navigate
  instead of broadcasting a level.
- `select(_:)` takes only a `Route`; an overload on `SidebarItem` made
  `select(.imports)` ambiguous.

The module originally carried a `DomainCodableBridge` with `@retroactive
Codable` conformances for `MapRegion`, `SearchScope`, and `TimelineQuery`. That
is deleted: the conformances now live on the types themselves in `CapsuleDomain`,
so the compiler synthesises them. The stopgap's real cost was silent — a new
`TimelineQuery` facet would have compiled but quietly failed to round-trip
through state restoration, because the coding keys were hand-maintained.

59 catalog keys for the sidebar, settings sections, onboarding steps, and menu
commands. 338 tests pass on macOS, iOS, and iPadOS.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
The app had a four-tab shell and a comment promising a split view "in phase 7".
This is that phase: one `Router` now drives a `TabView` on iPhone and a
`NavigationSplitView` on iPad and Mac, with each section owning its own
navigation stack so switching away and back restores where you were.

The shell is chosen from the live horizontal size class, not from the platform.
An iPad in Slide Over is genuinely compact and a Mac window dragged narrow
should behave the same way; `PlatformEnvironment` says only what a platform is
*capable* of. On macOS the size class is `nil`, and a Mac always wants the split
view, so the default is the correct answer rather than a fallback.

`RouteDestination` is a single exhaustive switch from `Route` to view. Adding a
route without a destination is then a compile error rather than a dead tap found
by a user. Destinations that are not built yet resolve to `RouteScaffold`, which
keeps the section's own name and symbol and says plainly that it has no
interface yet — deliberately plain, because a convincing mock would hide the gap
and the point is to make it visible. That is what lets the sidebar, deep links,
menu commands, and the UI-test sweep run end to end while screens are filled in.

The router's SwiftUI adapter (`binding(for:)`) lives in the app target rather
than on `Router`, because `CapsuleNavigation` imports no SwiftUI — which is
exactly what lets the router be driven and asserted on in tests with no view
hierarchy. `List(selection:)` takes an optional binding on iOS (the non-optional
overload is macOS-only), so the sidebar maps through one, ignoring the `nil`
SwiftUI sends on deselection rather than forcing a "no section" state.

Verified on all three destinations: builds, 338 tests pass, and the app launches
on an iPhone 17 Pro simulator, an iPad Pro simulator, and as a Mac app whose
menu bar carries Settings…, Import Photos…, Culling Review, and Capsule Help —
rendered as real text, which is the first proof the string catalog resolves on
macOS.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
This is not a stub. Until the Rust core is rebuilt it is the only thing behind
every port, so its behaviour *is* the app's behaviour — a reviewer has to be
able to walk every screen, including the failure states, and believe it.

The load-bearing design decision is that the library is a **pure function of
`(seed, index)`**: `MockLibrary` stores exactly one array — day boundaries,
sized by *days* rather than assets, about 29 KB for a decade — and derives every
field from a keyed splitmix64 hash. Library size is therefore a parameter, not a
memory cost. `.hugeLibrary` is 250 000 assets and constructs in well under a
second; `dayCounts` over it is O(days) and a page at offset 180 000 costs the
same order as offset 0.

That matters because the app's timeline is virtualized over a paged port and
`TimelineLayout` is tested against 3 650 sections and 250 000 items. A mock that
materialized an array would make the single most important property of this UI
impossible to demonstrate.

Everything derived is plausible rather than uniform: capture times shaped by
weekday and season, GPS clustered into twelve trips plus a home cluster, stacks
(RAW+JPEG, bursts, Live Photos), AI tags carrying model-slot provenance with
some deliberately stale, and per-asset representation ladders and sync states.
Thumbnails are drawn at request time with `CGContext` behind a 48 MB cache, so
no image bytes enter the repository.

`MockScenario` selects a whole coherent world, not a flag: `.offline` sets the
connection class, stalls uploads, degrades remote-only representations down the
ladder, and leaves every local read working — which is the offline-first
contract. `.quarantine` populates several distinct surfaces from the threat
model's table. `.newerVersionState` produces assets carrying `unknown(...)`
closed-enum values so the "created with a newer version" indicator and the
disabled-editing path are actually reachable. The raw values are the contract
with the XCUITest bundle, which cannot import this module.

Mutations genuinely mutate and emit on `changes()` — album creation bumps an MLS
epoch, the default album refuses deletion, tag removal is add-id gated, caption
edits supersede, and verify-before-destroy really refuses to release.

Ports that cannot be honest are documented as thin rather than faked: import
plans and streams progress but cannot grow a derived index, uploads move session
state without moving bytes, and search scans to a documented ceiling because no
inverted index exists.

70 tests pass; two real bugs surfaced while writing them — a stack's group cull
state derived from pre-edit rather than resolved assets, and cross-module actor
isolation on the ports handed out.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
XML forbids `--` anywhere inside a comment, and the generated banner read
``<!-- GENERATED by `cargo run -p xtask -- i18n` ... -->`` — the argument
separator lands right in the middle of one. aapt2 enforces the rule and fails
the *entire* resource merge, so every locale's `strings.xml` was rejected, not
just one.

The banner now names the task (`mise run i18n`) rather than the raw cargo
invocation, which is the command a contributor should be running anyway.

Like the qualifier bug this sits behind, it predates the branch and stayed
dormant because `build-android.yml` is path-filtered and nothing had touched
`capsule-android/**`. Fixing the invalid directory name was what let aapt2 get
far enough to parse the files and find this.

Guarded by a test that renders a document and asserts no comment body contains
`--`, so the banner cannot regress into invalid XML again.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
…ency

Launching the app showed a Photos permission prompt and an empty library. That
is right for the Rust-backed lane and wrong for this one: Local Gallery is
explicit that a native Capsule app is "a complete local gallery first and a
synced client second" (FR1), that never-signed-in is a valid mode (FR2), and
that gallery reads perform zero network I/O — "not tolerate failure, but do not
attempt" (NFR1). A mocked app that cannot show a library until the user grants
access to a different library is not that.

The composition root now builds from `MockEnvironment` and names no PhotoKit
type. `MockBridge` adapts the port world to the older provider protocols the
existing screens still consume, so the timeline, albums, and search render mock
data without being rewritten.

The interesting part is `PagedLibrarySnapshot`. `AssetSnapshot` is synchronous
and random-access; `LibraryPort` is paged and async. Rather than materializing
250 000 assets to bridge that, an unloaded index returns an explicitly
provisional `Asset` and schedules its window, so the next read is real. One
field is exact rather than provisional — `captureDate`, resolved by a prefix-sum
binary search over the day histogram `loadTimeline()` already fetched. A wrong
date would section the row into the wrong month and make the grid jump when the
real row arrived.

`isFavorite` is derived from a reserved user tag rather than a rating threshold.
The domain keeps rating orthogonal on purpose, and "rating ≥ 1 means favourite"
would silently zero a five-star photo's rating when it was un-favourited.

Three formatter rules are now disabled, each because it produces code that does
not compile inside a swift-testing macro:

- `hoistTry` moves `try` to the front of the expression, turning
  `#expect(!(try await port.sessions()).isEmpty)` into a form where `try` no
  longer covers the throwing call.
- `preferKeyPath` rewrites `allSatisfy { $0.flag }` as `allSatisfy(\.flag)`;
  `allSatisfy` is `rethrows`, and inside an `#expect` expansion the compiler
  stops inferring that the closure does not throw.
- `wrapMultilineStatementBraces` was already off for conflicting with SwiftLint.

Since every test here is a macro invocation, these are not stylistic
preferences — they are correctness constraints. A long ternary the formatter
had also mangled is rewritten as a guard, which reads better anyway.

One flaky test fixed properly rather than retried: it spun on `Task.yield()`
waiting for a detached fetch that hops onto the port's actor, which reschedules
on the same executor and can spin without the fetch ever resuming. It now polls
a real clock with a deadline.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Two of the four screen modules built in this pass are now in the graph, giving
the app its onboarding and identity surfaces and the full eighteen-section
settings tree — roughly 75 source files and 10 000 lines of screens, each with
a view model that takes its ports by constructor and is testable without a view.

`FeatureAuth` covers the fork that matters most: "Use without an account" is a
first-class choice rather than a skip link, because Local Gallery FR2 makes
never-signed-in a supported mode rather than a degraded one. It also carries the
six-step first-device enrollment ceremony with its type-back gate, the recovery
cadence prompt (7 → 90 → 180 days, snooze capped at three, never blocking), and
the device ledger whose copy asserts rather than litigates.

`FeatureSettings` renders one tree two ways — a grouped list on iOS, a tabbed
Settings window on macOS — and tells the truth per platform about the at-rest
posture, which is a sandbox guarantee on iOS and full-disk encryption on a Mac.

Three lint decisions worth recording, because each is a rule being wrong rather
than the code being wrong:

- `inclusive_language` flagged `masterKey` and `requiresMasterKeyProof`. This is
  not the master/slave usage the rule exists to catch: the master key is the root
  of the key chain (`capsule-core::crypto::keys::MasterKey`) and master-key proof
  is the specific authentication the docs require for `revoke_all_sessions`.
  Renaming it in Swift alone would put the client out of step with the design
  docs, the Rust core, and the wire contract. The term is allowed, and only it.
- `cyclomatic_complexity` flagged an eighteen-branch switch mapping settings
  sections to symbols. That switch is exhaustive over a closed enum on purpose —
  adding a section without a symbol should be a compile error. A lookup table
  would trade that guarantee for a better score.
- Thirteen `SettingsScreen(...) { ... }` call sites became explicit
  `content:` arguments, which the rule is right about.

`FeatureTransfer` and `FeatureSharing` are committed but **deliberately not in
the graph**. Both typecheck clean for iOS and macOS, and both crash the Swift
6.3 compiler in IRGen — `report_at_maximum_capacity` inside
`SyncCallEmission::setArgs`, reached from `IRGenSILFunction::emitSILFunction`
while compiling `ModerationView.swift` and `UploadDetailView.swift`. It is not a
type error and it survives stripping their `#Preview` blocks, so it needs
isolating rather than patching around. They are excluded from lint and format
too: findings on code that cannot be compiled are findings nobody can verify.

Also fixes a macOS-only name collision: `RGBColor` is ambiguous there because
ApplicationServices still exposes QuickDraw's C `RGBColor`, so the domain type
is now module-qualified at its use sites.

427 tests pass on macOS, iOS, and iPadOS.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Nineteen slices covering the whole anticipated Apple surface, plus the reason it
is a lane of its own: lane P sits entirely behind `S-P1`, so waiting on the SDK
would leave the client unbuilt at the moment the SDK lands — the worst possible
sequencing for the piece with the most design surface and the slowest review
loop. Lane U removes the dependency instead. `S-U19` is the only slice in it that
depends on anything outside it.

Also refreshes `capsule-swift/README.md`, whose feature list still described the
prototype: documents the timeline engine's aggregate-plus-prefix-sum design and
why `LazyVGrid` cannot answer it, the mock-scenario launch argument that is the
only way to reach roughly thirty of the screens, and what is honestly not wired.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`makeUniformGrid` handed a single `fractionalWidth(1)` item to
`repeatingSubitem:count:`, which is the documented pattern for an evenly
divided row and does not divide it. Every grid in the app rendered one
full-width tile per row — a five-column library came out as a stack of
400×80 bands, on iPhone, iPad and Mac alike. It compiled, ran, scrolled,
prefetched, and was wrong.

The item now carries the `1 / columns` fraction itself and the group is
given an explicit array of that many items, so the division does not
depend on either API's semantics. That also retires the UIKit/AppKit fork
in `horizontalGroup`, which existed only because `repeatingSubitem:` was
never added to AppKit — `subitems:` is spelled the same on both.

`UniformGridLayoutTests` measures resolved frames from a real
`UICollectionViewCompositionalLayout` rather than asserting the code
constructs what it meant to: column count per row, square tiles, and a
row that spans the full width. Nothing short of resolving the layout
would have caught this.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Day, month, and year bucketing all grouped *consecutive runs* of the same
period, on the documented assumption that assets arrive newest-first. When
that assumption did not hold, a period appearing in two non-adjacent runs
produced two sections sharing an id — and a diffable data source treats
duplicate section identifiers as a programmer error and raises, so the
process died before drawing anything.

That is not hypothetical: the 250 000-asset scenario terminated on launch
with `Duplicate identifiers: {("2026-07-12", "2026-07-11")}`. The scenario
that exists to prove the timeline scales could not open.

All three now coalesce by key, keeping each period's first appearance for
ordering and its first asset as the representative, so a correctly sorted
input is unaffected and an incorrectly sorted one renders slightly oddly
instead of terminating. One dictionary makes the failure unrepresentable,
which is worth more than the assumption was.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`Project.swift` is one file and these changes are inseparable in it, so
they land together rather than as commits that generate a broken project.

**Stop the mock lane asking for Photos access.** A fully mocked build
greeted the user with a permission prompt over the home screen, before it
had drawn a pixel. There is no call behind it: `AppEnvironment` constructs
no PhotoKit type and nothing in the tree constructs `PhotoKitProvider` at
all. iOS presents the limited-library alert automatically at launch for
any app declaring `NSPhotoLibraryUsageDescription` while holding a
`.limited` grant. Declaring it here was wrong on its own terms — nothing
in this lane can use it, and *Local Gallery* FR1/FR2/NFR1 say the gallery
works with no system access. The key now ships only under `TUIST_FFI=1`,
which makes the invariant self-enforcing: a mock-lane path that reaches
PhotoKit is terminated by iOS naming the call site, rather than quietly
prompting. `PHPicker` and `PhotosPicker` are out-of-process and need no
entry either way.

**Un-park `FeatureTransfer` and `FeatureSharing`.** The recorded diagnosis
was wrong on its central claim — the modules did not typecheck clean;
five files were missing a closing paren and never parsed, which masked the
real IRGen crash. That crash is fixed at its two call sites and both
modules are back in the graph, lint, and format.

**Add the remaining feature modules** — `FeatureAuth`, `FeatureImport`,
`FeatureSettings`, `FeatureSharing`, `FeatureTransfer`, and `FeatureAlbums`
(which had been arriving transitively through `FeatureCollections`; the
app imports it directly, so it says so).

**`CapsuleUI` now depends on `CapsuleDomain`**, so the design system can
render domain states — a seal, a cull flag, a sync tier. Deliberately not
on `CapsulePorts`: it must be unable to *fetch* one, which is why
`AssetWindowStore` is generic over a fetch closure.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Minimised to nine lines:

    let setter: @mainactor @sendable (Bool) -> Void
    Toggle("x", isOn: Binding(get: { true }, set: setter))

Forwarding a *stored* `@MainActor @Sendable` closure property straight
into `Binding.init(set:)` makes SILGen emit the reabstraction thunk
`@$sSbScA_pSgIeAghyg_SbIeAghn_TR`, and lowering it asks the IRGen
`Explosion` for capacity 4294967297 — a `0 − 1` underflow, which is the
`report_at_maximum_capacity` abort inside `SyncCallEmission::setArgs`.
Wrapping the call (`set: { setter($0) }`) emits an ordinary closure and no
thunk. Two sites, identical rendered UI, a one-line reason at each.

It had nothing to do with `#Preview`, view-body size, or modifier chains,
which is what the parked note guessed. That note also claimed both modules
typechecked clean; they did not — five files were missing a closing paren
and never parsed, and that is what hid the real crash for as long as it
did.

Also here, surfaced only once the modules could compile: a
`LocalizedStringKey` built from a `String?` in the storage-reclamation
consumer row, and a `void_function_in_ternary` misfire rewritten as an
`if`/`else` expression rather than suppressed.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`TimelineLayout` already answered *where* item 148 302 sits from a
`(dayKey, count)` aggregate. Nothing answered *what* it is.

`AssetWindowStore` is that half: a sliding LRU window of fixed pages over
a fetch closure, which cancels fetches that scroll out of margin rather
than awaiting them, discards results by generation when the library moves
underneath, and never evicts a page the viewport still needs. It is
generic over a closure rather than over a port on purpose — `CapsuleUI`
has no business knowing what a `LibraryPort` is, and a closure makes the
paging testable against a counter instead of a library.

Two things `@Observable` made non-obvious and both are commented at the
site: every stored property is tracked by default, so the LRU bookkeeping
that `element(at:)` does as a side effect had to be `@ObservationIgnored`
or a view body would mutate tracked state and invalidate itself forever;
and a `@MainActor` type's `deinit` is not actor-isolated, so cancellation
on teardown is an explicit call instead.

`TimelineAttributeSolver` and the collection-view layouts bind the engine
to the grid, answering `layoutAttributesForElements(in:)` from one binary
search plus one frame lookup per *visible* item — no cached attribute
dictionary, no per-item pass. That claim is tested by counting queries
through a wrapping geometry, not by reading the output.

Also fixes a real bug in `rowAlignedItemOffset`, found by building on it:
it clamped to `rows × columns`, which differs from the item count when the
last row is ragged, so a viewport edge landing in an inter-section gap
skipped the first items of the next day.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Five semantic roles rather than a palette — settled, in-flight, waiting,
degraded, attention — so contrast is proven once per role instead of once
per site, and every Capsule state maps onto one of them.

Two judgements are encoded rather than left to each caller. Over a
photograph, every non-actionable role collapses to white-on-scrim and only
`attention` keeps a hue: photo content is arbitrary, so a colour that
carries meaning against a settings background carries none against a
sunset. And `writtenByNewerVersion` maps to `attention` rather than
`degraded` — the data is intact and preserved verbatim, but this build
will not write it back, and a user who edits it elsewhere and loses the
round trip was never told. The copy that goes with it says "created with a
newer version", never "damaged".

`SyncStateBadge` renders all eight sync states; `durable` renders nothing,
because it is what almost every asset is almost always and a badge on
every cell is not information. `AssetCellOverlay` fixes the four corners
by meaning so the eye learns them once, and takes a stack's member count
as a parameter — `StackMembership` carries only this asset's role and
ordering, since the size of a stack is a fact about the stack.

Culling flags deliberately keep their own colours instead of borrowing the
sync roles: a rejected photo is a decision, not a fault, and drawing it in
the alarm colour would make a review pass look like a failure report.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Sixteen views over the view models that already existed: the enrollment
ceremony, the recovery passphrase reveal and type-back gate, the
re-verification cadence, devices and sessions, cross-device add, restore,
passkey and TOTP — with 137 tests. Every file is small and every `body` is
a handful of elements; the IRGen crash made expression shape load-bearing,
and the habit is worth keeping regardless.

Five bugs, each found by a test author who declined to assert the buggy
behaviour and flagged it instead:

- **`SecretPasteboard` never wrote its concealed marker on macOS.**
  `setString(_:forType:)` writes nothing and returns `false` for a type the
  pasteboard has not been told to expect, and a custom type is never
  implied — so the entire macOS mitigation was silently absent while the
  recovery passphrase itself was still written, leaving it to clipboard
  managers. Now declared before it is set.
- **`canReconstructFromShares` counted invalidated Shamir shares**, so one
  live share plus one dead one enabled reconstruction that could not
  possibly work, failing at the port mid-recovery. Dead shares stay
  selectable — they are shown so a holder learns they are dead — but they
  no longer count.
- **The mock's recovery generator emitted six words**: 66 bits against a
  128-bit floor, so every preview of the passphrase screen sat in its
  "this is a defect in this build" state — the one state that screen
  exists to prove is not happening.
- **`SettingsConnectivity.isOffline()` used `!isUsable`**, which is also
  false for an unknown connection class from a newer writer. That made
  `phase(for:)` return `.offline` and discard the real error code, telling
  a user on a working connection to check their network.
- **`commitRestore` returned `true` for a restore that threw**, because
  the error was swallowed into `phase`. On that screen it means dismissing
  the flow with the user believing their library is back.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
**Import** — source picker, scan, plan-and-confirm, execution, history,
with ~60 tests. The plan screen is the one that carries the product's
argument: three stat tiles over a free-space meter that goes amber on
`streaming_recommended` and red on the hard case, and a destination row
that states its album *and the resolution rule that fired*. The docs
require the fired rule be recorded, so the UI explains itself rather than
showing a bare destination the user cannot account for.

Free-space verdicts are derived client-side rather than baked into the
plan, because free space changes while the screen is open.

New ports are one per screen need, not a grab-bag: `resolveScope` exists
because a scope id is computed in `capsule-core` and never in Swift, so a
user-picked folder has to round-trip; `scanStream` has no cancel call
because a scan writes nothing and tearing down the consumer is a complete
stop; `replan` returns a *plan*, so a re-run passes back through
confirmation rather than silently repeating.

**Settings** — 147 tests over the eighteen sections, and the mock world is
deliberately not the injection point for most of them: it is coherent and
healthy by construction, so it cannot produce the empty and failed states
each screen has to render.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
`RouteDestination` resolved 9 of 38 routes and swallowed the other 29 in a
single `default:`. It now resolves 27, dispatching to small per-area
properties, and the remaining 11 are enumerated by name rather than caught
by a fallback — so adding a `Route` case is still a compile error and not a
dead tap found by a user.

Routes that carry an id no screen accepts go through one generic resolver
that owns the resolve-state, so `RouteDestination` owns none. Neither
`QuarantinePort` nor `DropPort` has a read-by-id, so it walks the first
page; an item that is gone renders "no longer here" rather than an error,
because it isn't one.

The settings index is built from `SettingsRootCatalog` — groups, order,
symbols, completeness — instead of a second hand-maintained list that
could drift from the first. Its rows push a `SettingsSection` rather than
a `Route`, since pushing `Route.settings` would resolve back to the index.

Two navigation bugs found and recorded rather than quietly patched:

- **Nothing in the app pushes a `Route`.** Every detail route is wired and
  unreachable until a screen adds a `NavigationLink(value:)`; `quota`,
  `maintenance` and `onboarding` have no sidebar row, deep link, or menu
  command at all.
- **The iPhone shell drops 15 of 19 sections.** `CompactShell` renders
  only `SidebarItem.tabs` and there is no surface for the overflow,
  despite a doc comment claiming they are reached "through the Collections
  section" — there is no such tab. Transfers, Imports, Shares, Drops,
  Quarantine, Devices, Peers, Federation, Storage, Memories, Duplicates,
  Trash, Hidden, People and Places are unreachable on a phone.

Verification is shell-agnostic on purpose: a swift-testing suite asserts
each route is a scaffold *iff* it is on the declared list, by reflecting
the rendered tree so the `switch` stays the single source of truth, and a
UI sweep walks sidebar rows on iPad and tab-bar buttons on iPhone.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
1,453 new keys, taking `locales/en.json` from 547 to 2,000 entries, every
one with a `context` field, regenerated into the Rust bundle, web JSON,
Android `strings.xml` and the iOS `.xcstrings`.

That is far more than the guard could see. `i18n-guard` only understood
stock SwiftUI call shapes, so keys passed as `titleKey:`/`labelKey:`
parameters and keys built by interpolation were outside it entirely —
several hundred strings that compiled, rendered as their own raw text, and
failed nothing. Every Swift file was read directly rather than trusting
the worklist.

Copy follows the domain rather than the key names, which is why each call
site was read first. Cohort text asserts instead of hedging. Quarantine is
*held*, never damaged, and offers Inspect / Repair / Discard with an
explicit note that there is no default. `durable` is the only state that
offers release. `awaiting_original` and `newer_version` read as
informational, not as faults. Degraded federation says the shortfall is
reach, not loss. A drop's filename is labelled as given by the sender
*before* the name is shown. And the below-floor entropy string is worded
as a fault in this build, because that is the only thing that can produce
it.

Eight strings use printf placeholders rather than ICU, because their call
sites are `String(format: String(localized:))`; their contexts say what
each value holds. The twelve non-English catalogs are untouched — absent
keys fall back to the source locale, and machine-translating 1,453 strings
would defeat the human review gate.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Two holes in `i18n-guard`, both of which let a string fail silently at
runtime rather than loudly at build time.

**Swift's `ErrorCode` is now checked against the catalog.** The Rust codes
are generated from `locales/en.json` and cannot drift; the web reads the
catalog directly. Swift's is a hand-written enum whose raw values *are*
catalog keys, so it is the one surface where the contract can rot — and it
rots into the worst symptom, a lookup miss that renders a blank message on
exactly the screens a user reaches when something has already gone wrong.

The two directions are deliberately asymmetric. A code in Swift the
catalog lacks is fatal: there is no message to show. A code in the catalog
that no Swift source mentions is only reported, because the enum's
`unknown(String)` case is load-bearing by design — a newer server may send
a code this build predates, and the raw value *is* the key, so it still
localizes. Making that fatal would force a Swift change for every
server-side error addition, which is the coupling `unknown` exists to
avoid. It is measured against every Swift source rather than the enum
alone, so client-local strings reached through `unknown(_)` count as
handled; a gate that reports noise is a gate people learn to skim.

**`…Key:` parameters are now scanned.** The detector knew only stock
SwiftUI call shapes, so `titleKey:`, `labelKey:`, `emptyDescriptionKey:`
and a dozen more were invisible — a typo'd or never-added key there
compiled and rendered as its own raw text. The leading character class
excludes `.` as well as identifier characters, because an argument label
is never preceded by a dot but an enum case in a switch always is: `case
.masterKey: "key.fill"` returns an SF Symbol, not a catalog key, and was
the first false positive the widened detector produced.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Corrections to claims made a few commits ago that ran ahead of the code:
`S-U5` was marked done while the collection view still used the prototype
grid, and `capsule-swift/README.md` said the engine "backs every grid",
which it did not yet. Both now say what is true, and the binding is owed
to `S-U7`.

Also adds a status note to the clients design doc explaining why the Apple
client is built against ports rather than the SDK, and that doing so
relaxes no client duty — `verify_asset` quarantine states, forward-version
refusal, and the unreadable-on-this-device surface are all reachable and
tested in the mocked client. A client that cannot *show* a quarantine is
not one that can be trusted to enforce it.

`capsule-swift/build/` is now ignored: `xcodebuild -project -target`, which
is how a single module is built without the workspace scheme, writes its
products there rather than into DerivedData.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
Resolves three SLICES.md conflicts, all of them the two branches recording the
same Apple-toolchain repair from different halves of it. Merged rather than
picked: upstream found the root cause (a stale DVTDownloads.framework, and the
documented fix for it removing CoreSimulator.framework), this branch found the
fault behind it (Xcode 26.6 ships the iOS 26.5 SDK, the host had only 26.0/26.3
runtimes, so xcodebuild enumerated no simulator destinations at all). Both lanes
build locally now, so both rows say so.

Row counts recomputed from the table rather than taken from either side: 148
rows, 63 ACTIVE / 49 RETIRED / 36 MIXED, 49 done / 21 done* / 70 ready /
3 blocked / 4 post-v1 / 1 part-done.

S-U19 flips blocked -> ready. Upstream's own 4b950a1 unblocked lane P when S-P1
landed but could not touch S-U19, which does not exist on that branch; leaving
it blocked would have misreported the merge.

Claude-Session: https://claude.ai/code/session_016vJa976u11Y7XvpVq3dcG6
justin13888 and others added 24 commits August 29, 2026 01:17
`S-U5`'s note said the collection-view binding was owed to `S-U7` and the grid on
screen was "still the prototype's". Both are now wrong in an instructive way, so
the note says what was measured rather than being quietly deleted.

The binding was assumed to be the prerequisite for a continuous headerless grid.
It is not: one 250 000-item uniform section resolves in a compositional layout in
8.7 ms with viewport queries in tens of microseconds, while the 3 650 day
sections it replaced cost 426 ms. The boundaries were the expense, not the tiles
— so removing the headers was a ~50× win on the *existing* grid and the
virtualized engine was not needed to get it.

What the engine is still owed for is recorded too, because "unused" and
"unnecessary" are different claims: it renders a library the app has not
materialised, and `TimelineViewModel` still builds an `[Asset]`. Clearing that is
entangled with how hiding is *stored* — the timeline filters `HiddenStore`
client-side out of that array and no client-side filter survives virtualization
— which is a data-layer change and does not belong in a UI slice. Writing that
down is what stops the next person rediscovering it halfway through.

S-U7 and S-U8 move to `done*` with their remainders named: culling review for
one, provenance and superseded captions for the other.

Claude-Session: https://claude.ai/code/session_012pkMhSnJe2199A9rNixnxL
The accessibility audit caught a regression the previous commit introduced, and
the audit is only able to say so because the failure was classified against the
pre-series tree rather than waved off as "the audit was already red".

`viewer.adjust` measured **45 × 19.3 pt**. A bar glyph with `maxWidth: .infinity`
is as tall as the glyph; the padding that makes it *look* like a comfortable
target belongs to the glass capsule around it, not to the button, so the tappable
region was 19 pt tall. That looks right in a screenshot and is wrong under a
thumb — in a viewer where the neighbouring control deletes the photo.

The minimum is stated once, on the shared label and on the two circular buttons,
rather than at each call site. Apple's 44 pt, which is also what the audit
checks.

With this the viewer's audit returns to the failure the base tree already had —
`dynamic-type` on the timestamp in `viewer.title` — which is pre-existing and
not this series' to close. The huge-library audit's timeout is likewise
identical at base. Both were verified by running the audit in a worktree at
`13a799d`, not assumed.

Claude-Session: https://claude.ai/code/session_012pkMhSnJe2199A9rNixnxL
Both branches localized the Apple client at the same time, from opposite ends.
`feat/ios-app` renamed 1,635 keys from `ios.*` to `app.*` because a shared
skeleton should not be named after whichever client was written first;
`feat/v1-code-push` kept adding to `ios.*` while it landed `S-I4`, the SR1 trash
gate, and an Apple String Catalog generator that compiles ICU instead of copying
it. The two halves are complementary, and nothing here drops either one.

**The catalogs merged by key, not by line.** Every incoming key was renamed
through the same map the refactor used (`ios.menu.*` to `apple.menu.*`,
everything else to `app.*`), then merged three-way against the base. Eleven of
theirs described concepts this branch already carries under other names —
`ios.media_type.*` is `app.media.*`, `ios.search.date_range.*` is
`app.search.filter.date.*` — and are dropped rather than duplicated. Seven more
were the only strings of screens this branch deleted (`CollectionsRootView`'s
parallel category vocabulary), and their own commit's rule applies: no dead
strings through thirteen translations. `ios.auth.reason.*` becomes
`app.<screen>.auth.reason` to sit beside `app.hidden.auth.reason`, which was
already there.

**The SR1 gate needed a home in a split module.** `da2d9108` put `GatedView`,
`LocalAuthGate` and `LocalAuthError` on the FFI-free `AssetCatalog` protocol,
which `419bf1ea` had already made unable to name a generated type. They are now
Swift-native mirrors in `CapsuleCatalog` with a `GateFFIBridge` mapping them at
the boundary — the discipline `CatalogError` already established, extended to
`CatalogError.viewLocked` and to `FFIAssetCatalog`'s four gate methods.
`InMemoryAssetCatalog` gains the grants and the hidden-row exclusions that
commit added to the old `MockCatalog`.

**The mock lane had no core to hold the grant.** `ManagedProvider` takes it in
Rust; `PortBackedTrashProvider` has only ports behind it, so it holds the grant
itself over the `LocalAuthenticator` seam this branch built, with the same
five-minute window. `TrashProvider` therefore answers `unlockTrash()` the same
way in both lanes, and `RecentlyDeletedView` — which had no gate at all here —
needs no idea which one it is talking to.

**Three findings the merge produced rather than inherited.** The interpolated-
literal detector from `S-I4` and the computed-property detector from `13a799d9`
only exist together on this branch, and together they caught two hardcoded
English strings this branch had shipped (`AlbumCoverCard`'s photo count, the
Library's delete confirmation) — so `app.albums.photo_count` is restored rather
than dropped. `FannedAssetStack` was asking for `app.places.preview.accessibility
%lld`, because SwiftUI folds an interpolation into the key itself; VoiceOver was
reading the key aloud. And that key's message was an ICU plural embedded in a
sentence, which the new generator refuses — correctly, since a language that
inflects "at this place" with the count cannot be served by a fixed suffix.

**`Project.swift` keeps both plist positions.** `NSFaceIDUsageDescription` is
declared unconditionally, as `da2d9108` had it: it prompts for nothing and a
build that reaches `evaluatePolicy` without it is terminated.
`NSPhotoLibraryUsageDescription` stays behind the FFI lane, as `6ac90504` had
it: declaring it in the mocked lane put a Photos prompt over the home screen for
access that lane cannot use.

Verified rather than assumed: `mise run check-swift` green (format, lint, unit
tests on macOS / iPhone 17 Pro / iPad Pro 13-inch, and the iPhone UI sweep),
`mise run check-web` green, `mise run check-docs` green, `cargo nextest run
--workspace` 1,702 passed, and clippy, `i18n-check`, `i18n-guard`,
`architecture-check` and `translate-readme-check` all clean. Kotlin is unverified
— Gradle does not run on this machine — but no `R.string.*` reference in the
Android tree names a key the merge moved.
The only conflict is the SLICES.md count paragraph, which both sides
rewrote: this branch added the 19 rows of lane U, and the base added the
notification lane's two (`S-Z11`, `S-D29`).

Resolved by recounting the merged table rather than picking a side —
201 rows, 83 ACTIVE / 80 RETIRED / 38 MIXED, and 94 done / 54 done* /
39 ready / 7 blocked / 4 post-v1 / 3 part. The prose keeps this branch's
reading of the blocked set (only `S-N2` and `S-P4` wait on code; the
other five wait on a decision) and the base's note that the table spells
the three partial rows `part` and `part 1 done`.
`check-web` stopped being pure bun when S-E1 and S-D3 put `build-wasm`,
`share-kat` and `drop-kat` under `test-web`/`build-web`. The `web` job's
step list predates that and was never updated, so the gate has not passed
once since: the last green run is master `cb230755`, where `test-web` had
no `depends` at all.

Three things were missing, and each alone is fatal:

- No toolchain install. Rust is rustup-on-PATH here, resolved lazily from
  `rust-toolchain.toml`, so all three tasks raced that bootstrap into one
  `~/.rustup/downloads` and clobbered each other's `.partial` — "could not
  rename 'downloaded' file", on `cargo` one run and `clippy` the next.
- No `wasm32-unknown-unknown`. It is not in `rust-toolchain.toml`, which
  pins the iOS targets; the `rust` job adds it explicitly for the same
  reason.
- No `wasm-bindgen`. It is a `[tools]` pin, but this workflow sets
  `MISE_AUTO_INSTALL=false`, so it has to be named. It is installed after
  the rustup step, never before — cargo-built mise tools race the
  toolchain bootstrap, as build-ios.yml already records.

Also orders the three cargo tasks behind one another rather than leaving
them parallel siblings. Warming the toolchain in CI removes the download
race, but they still contend on the single `target/` lock, which is the
stated reason `check` and `check-rust` are sequential — and ordering fixes
a cold local `mise run check-web` too, which a CI-only change would not.
The module has never built. `TUIST_FFI=1` puts uniffi's generated Swift in
the *same* module as the hand-written bridges, so four generated names —
`CatalogError`, `GatedView`, `LocalAuthError`, `LocalAuthGate` — shadow the
`CapsuleCatalog` types they are bridges *to*. A same-module declaration
outranks an imported one silently, with no ambiguity diagnostic.

`GateFFIBridge` and `CatalogErrorFFIBridge` both diagnose this and define
`Native*`/`Generated*` aliases telling the reader to use them rather than
the bare names. Three places did not:

- `FFIAssetCatalog`'s three gate methods took the *generated* `GatedView`
  and `LocalAuthGate`, so they witnessed none of the three gated-view
  requirements and the type failed to conform — the error naming only the
  protocol, never which requirement went missing. Module-qualified rather
  than aliased, because the aliases are internal and cannot appear in a
  public signature.
- `FFIAssetCatalogTests` imports both modules, where the bare name is an
  ambiguity error rather than a silent bind.
- `FFISidecarCoderTests` never imported `CapsuleCatalogFFI` at all, so
  `FFISidecarCoder` was out of scope — and adding the import makes its two
  `CatalogError` uses ambiguous, so both halves land together.

Verified by building the `TUIST_FFI=1` graph, which now succeeds.
…each

`receive` records a fetch failure and deliberately does not mark the page
`exhausted`, on the stated grounds that "a failure is worth retrying when
the page is next required". It is not. `setVisibleRange` returns early on
an unchanged range, before the refill loop, and `startFetch` has only two
other callers — so a viewport sitting still over a failed page held its
placeholder until the user scrolled far enough to change the required page
range and came back. The only recovery was `invalidate()`, which drops
every resident page to repair one.

Refilling from `setVisibleRange` regardless would be worse: it is called
every frame, so a page that fails fast would re-issue a failing fetch every
frame for as long as the user looked at it. So the retry is explicit —
`retryFailedPages()`, for whatever surfaces `lastError` — and the comment
now says what the code does.

Tests cover both halves: that twenty identical `setVisibleRange` calls
after a failure issue no further fetches, and that `retryFailedPages()`
recovers without the viewport moving.
`ManagedProvider.purge` removed the catalog row and left the bytes on
disk — "the on-disk file cleanup is a follow-up", per its own comment. So
the operation a user reaches for when they want a photograph *gone*
reported success while the file was still there. For a product whose
posture is local custody, that is the wrong direction to fail in.

Adds `ManagedLibrary.removeAssetFiles(uuid:captureDate:)`, which sweeps
`{uuid}.*` from the capture-date partition. Addressed by stem because the
catalog records no file extension — the import derives one from the source
filename — and matching the stem takes the `.cbor` sidecar with it. A
missing file is not an error, so a second purge converges.

Bytes are removed *before* the row, and the ordering is the contract: if
the file cannot be deleted the call throws and the asset stays in Recently
Deleted, where it can be tried again. The other order's failure is a row
over vanished bytes — a broken thumbnail. This order's failure is a
vanished row over bytes still on disk, and the two are not comparable.
`AssetViewerView` is fully built and is presented as a full-screen cover
from the timeline, album detail, search and places. The `.viewer` *route*
resolved to `RouteScaffold`, so the one entry point that goes through the
router — `capsule://asset/<uuid>`, which the deep-link parser accepts and
turns into `.viewer(id, context: .library)` — parsed correctly and landed
on a placeholder.

Resolves the sequence through the existing `ResolvedDestination`, which
already owns the resolving / resolved / no-longer-there states and their
catalog keys, so this adds no new strings and no new loading machinery.

Only `.timeline` sequences resolve, because only they can be answered from
`loadTimeline()`. That is the honest boundary rather than a shortcut: the
deep link is currently the sole producer of `.viewer` and always emits
`.library`. An album, person, place or search sequence needs its own
collection query, so those keep the scaffold rather than get a viewer that
cannot page past the asset it opened on — and the contract list in
`RouteDestinationTests` now says exactly that, with a census row for the
non-timeline case so the claim is asserted rather than asserted-by-absence.
…o control

Security & Privacy has a per-device "resolve place names" toggle that
persists to `UserDefaults`. Nothing read it. `SystemPlaceNameResolver` —
the only reader of `PlaceNamePreference.isEnabled` — was constructed
exactly once in the whole tree, in a test: every viewer defaulted to
`NoPlaceNameResolver`, and none of the four `AssetViewerView` call sites
passed one. The preference was write-only, so the toggle did nothing in
either position.

It failed safe, which is why it went unnoticed: no geocoding happened. But
a control that reports a setting it does not have is worse than an absent
one, so it is now wired rather than removed.

`AppEnvironment` composes the real resolver and hands it to every screen
that presents the viewer, including the newly routed `.viewer`
destination. The default at the view stays `NoPlaceNameResolver` — that is
deliberate, so a viewer built without an explicit resolver still cannot
make a network call by accident, and the composition root is the one place
that opts in. Off-by-default behaviour is unchanged: the per-device consent
check lives inside the resolver, not at its call sites.
Six rows read `ready` while their screens had partly landed, one read
`done` while three of its eighteen sections were scaffolds, and five real
gaps were owned by no slice in any lane.

- `S-U9`–`S-U14` become `part`, each with an owed note naming what landed
  and what did not. Not `done*`: their deliverables are much larger than
  the screens that shipped — `S-U9` alone still owes the predicate builder,
  member avatars, role badges and epoch chips.
- `S-U15` becomes `done*`. Federation, advanced and about are scaffolds,
  and the Advanced mock-scenario switcher its own slice note relies on as
  the way to reach the otherwise-unreachable screens does not exist (#392).
- `S-U8` records that the `.viewer` route landed.
- Five orphaned gaps get rows: `S-U20` memories, `S-U21` duplicate review,
  `S-U22` inbound link redemption, `S-U23` the two onboarding steps.
  `memories` returned zero hits in this file while `SidebarItem.memories`
  was a live sidebar row; `duplicates` returned only server-side blob
  dedupe, which is a different concern.

Row counts recomputed from the table rather than edited by hand: 205 rows,
87 ACTIVE / 80 RETIRED / 38 MIXED, 93 done / 55 done* / 37 ready / 9 part /
7 blocked / 4 post-v1.
feat(swift)!: build the Apple client for iPhone, iPad, and Mac
`starlightLinksValidator()` proves every link inside the documentation
site on each `build-docs`, and that is why the site has none broken. It
cannot see a link that leaves the site, and three kinds do: `SLICES.md`
and `AGENTS.md` reference design docs by repo-relative path with an
anchor, the thirteen root READMEs reference the published site by
absolute URL, and a leading-slash path reads as repo-relative to a human
while resolving to nothing on GitHub.

Nothing checked any of them. That matters now rather than later: the
design-doc restructure this branch is opening moves and renames files,
and 241 `SLICES.md` links plus 39 README URLs would have broken with no
signal. The gate lands before the first move so it guards that work
instead of auditing it afterwards.

It found fifteen, which reduce to three defects. `CONTRIBUTING.md`
pointed at the development docs with a leading slash. `SLICES.md` cited
`design/drops.md`, which has never existed — the drop surface is owned
by `web-upload.md`, as `authentication.md` and `api-surfaces.md` both
already say. And `capsule-android/README.md` was missing while all
thirteen READMEs linked it, so one absent file was thirteen findings.

Writing that README meant reading the module, and it does not compile:
`ListScreen`, `DetailScreen` and `CapsuleApp` reference
`data.MuseumObject`, `di.initKoin`, `ListViewModel` and
`DetailViewModel`, none of which exist anywhere in the repository. It is
the Kotlin Multiplatform starter template with its data, DI and
view-model layers removed, and no screen touches `capsule-core-kotlin`.
The shared library below it does not build either — its two smoke tests
call `FfiWorkspace.create` and `createWithHardwareSigner` without the
`client: FfiClientBuild` argument both constructors have required since
`capsule-core::ffi` gained client build identity. The README says so
rather than describing the app the module is named after; repairing the
Kotlin lane is code, and is not this change.

**Where it runs.** Its own job, not `check-rust` and not `check-docs`.
`check-rust`'s paths filter excludes `capsule-docs/**`, so a docs-only
pull request would skip it there, and widening that filter makes every
`docs(design):` commit pay for a full cargo build. `check-docs`'s
expensive step is the Astro build, which this needs no part of. The
scripts import only `node:` builtins, so the job installs nothing — the
absent `bun install` step is deliberate and says so.

The gate proves that a name resolves. It does not prove that the prose
around it is true, and the entrypoint states that boundary so it is not
oversold and then disbelieved the first time it misses something.

**The slugger is GitHub's, and three details of it are load-bearing.**
Whitespace runs are preserved: `## Damage Scenario → Invariant Map` is
`damage-scenario--invariant-map`, and collapsing the run rejects five
links `SLICES.md` gets right. Underscores survive, or the four headings
carrying an identifier are silently renamed. And the character class is
Unicode-aware, because `\w` is ASCII-only and drops `## 機能` to the
empty string — 142 headings across the translated READMEs.

Also wires `test-docs`. `capsule-docs` has declared a vitest script and
carried a `rehype-notranslate` suite for some time, and no mise task or
CI job has ever run it; the eight tests in it pass. The thirty-five new
ones needed a runner, and shipping a gate whose own tests nothing
executes would have repeated the defect this commit is about.
`principles.md` says every doc carries a `status` tracking human review,
and the site schema declared the field `.optional()` — so eight pages
carried none: all three of `development/`, all three of `guides/`,
`features/smart-albums.md`, and the splash page. Nothing flagged them,
because an optional field is satisfied by omission.

Dropping `.optional()` makes `astro build` reject a page without one,
which means `mise run build-docs` and the `docs` CI job already enforce
it — no new check, no new wiring. Verified by removing the field from a
page and watching the build fail with `status: Required`.

The eight are marked `draft`, which is what they are: not one of them
has been through the review the field records. That is also the honest
starting state for the audit this branch is doing — `draft` is the
claim that no human has signed off on the page as written, and for
these eight it has always been true and merely unstated.

`principles.md` gains the scope it now has: the field is every page's,
not only a design doc's. A guide that tells someone how to self-host is
exactly as capable of going stale as a doc that specifies a wire format,
and the review queue should be able to see both.
`architecture-check` has forbidden Salvo, gRPC and Progenitor by name
since the teardown, and `salvo`, `tonic`, `tonic-prost`, `prost` and
`prost-types` have been sitting in `[workspace.dependencies]` the whole
time. The check reads `cargo metadata`, which reports member packages
and their dependencies; a workspace dependency no member inherits is not
a member dependency, so it was never looked at. Cargo does not lock one
either, which is why `Cargo.lock` did not give it away — and why this
commit removes fourteen declarations without touching the lockfile.

The second rule matters more than the first. A declaration nothing
consumes is a claim nothing can falsify, and the tree had a load-bearing
example: `testcontainers` and `testcontainers-modules` are cited by nine
design docs as the basis of a smoke tier, no test in the workspace
starts a container, and `local-development.md` says so in as many words.
Two documents disagreeing about whether a thing exists is a documentation
problem; a manifest that supports neither of them is a tooling one.

So an unused workspace dependency is now a violation unless
`PLANNED_WORKSPACE_DEPENDENCIES` carries it with a reason. Five entries
qualify, all sanctioned by decisions already written down: `redis`,
`bb8` and `bb8-redis` are the Valkey adapters `AGENTS.md` requires for
the two typed state ports, and the two testcontainers crates are the
smoke tier the design docs specify. Naming them keeps the pin *and* the
fact that nothing uses it yet in the same place, instead of leaving the
manifest to imply the first and nothing to record the second.

Removed: `salvo`, `tonic`, `tonic-health`, `tonic-types`, `tonic-prost`,
`tonic-prost-build`, `prost`, `prost-types` — retired transports, and
`AGENTS.md` forbids reintroducing them — plus `data-encoding`,
`derive_more`, `file-format`, `futures-util`, `num-rational` and
`tower-http`, which no member has referenced since the Salvo tree left.
`file-format` in particular is media detection, which Rawshift owns.

Three tests guard the exemption list rather than the code: a name cannot
be both retired and planned, every entry states a reason, and the set is
asserted whole so growing it is a deliberate edit and not a drive-by.
The design docs carry roughly ninety passages that are not contract:
"is gone, not deprecated", "the earlier gate is amended to 0.7.1",
"Considered and rejected: ThumbHash on its merits", a whole section of
`api-surfaces.md` documenting a GraphQL surface that was deleted. They
are the project's memory, and they are worth keeping — but a reader
implementing the upload protocol is reading a specification, and each
one makes the specification longer without making it clearer.

This adds the place they go, and nothing else: no design doc is edited
here, so the format can be judged on its own before ninety passages are
moved into it.

The rule that decides is written down rather than left to taste. A
passage is rationale if deleting it leaves a reader unable to derive the
contract's shape from the contract alone; it is history if deleting it
only leaves them unable to say what the project used to think. Rationale
is the best thing about these docs and it stays inline.

`adr/` is a plain directory, not a site section, for the same reason
`SLICES.md` is: the site is the specification an implementer reads, and
the project's memory belongs beside it rather than inside it.

One field departs from the usual ADR template. **`Contract:`** links the
doc that now states the decision normatively, which is what makes the
split checkable — an ADR with no live contract is unfalsifiable prose,
and nobody can tell whether it still describes the system. Writing it as
a link rather than a bare path means `check-docs-truth` already resolves
it, so an ADR pointing at a renamed or deleted doc fails the build. That
reuses the gate landed two commits ago instead of adding a second one.

Three records to start, all decisions already taken and already narrated
across several docs each: the single Kynos REST surface, Spargen over
Progenitor with the generated/hand-written line redrawn, and Chromahash
0.7.1 with ThumbHash retired. Each collapses narration from three or
four docs into one record — one ADR per decision, not per passage,
because per-passage records would reproduce the scattering this
directory exists to end.
…es them

`capsule-server/openapi.json` is committed, regenerated by
`mise run openapi-kynos` and drift-gated inside `check-rust`. That makes
it the one machine-readable statement of what the server serves, and
therefore an oracle for every endpoint a design doc names. Nothing was
using it that way, and 64 of 83 citations did not resolve.

Almost all of it was one systematic drift. The Salvo surface was
unversioned and the Kynos one is not: 42 of the contract's 51 paths sit
under `/v1`, and the docs had never been moved. So `POST /upload`,
`GET /blob/{hash}`, `GET /sync`, `GET /quota`, `POST /storage/verify`
and the rest name paths that do not exist, in the documents an
implementer would copy them from.

The rest is parameter drift, which is why matching is **exact on the
path template** rather than normalizing `{id}` to a wildcard. Normalizing
would pass `POST /v1/albums/{id}/upgrade` against the real
`/v1/albums/{album_id}/upgrade`, and that class — `{opaque-id}` for
`{opaque_id}`, `{id}` for `{album_id}` and `{drop_id}` — is precisely
what defeats reading a doc and grepping for what it names.

Four were not mechanical:

- `api-surfaces.md` mapped the upload surface as `POST/HEAD/PATCH
  /upload` on one path. `POST` opens a session at `/v1/upload`; `HEAD`
  and `PATCH` act on `/v1/upload/{id}`. The surface map, of all places,
  had the shape wrong.
- The same table gave guest drops as `/u/{opaque-id}/drop`, which is not
  an API path at all. `web-upload.md` already says `/u/{opaque_id}` is
  the *page* a guest opens — `capsule-web`'s route — and the endpoint is
  `POST /d/{opaque_id}`, which is what the contract serves.
- `POST /drop` appears nowhere in the contract; the drop session is
  created at `POST /d/{opaque_id}`. That one was a heading in
  `validation.md`, so its anchor moved and the link in `web-upload.md`
  moved with it.
- `/u/` is now excluded from the paths the check recognises, because it
  is a page namespace rather than an API one. A doc naming it is right.

Two citations stay unresolved on purpose and are allowlisted with their
reasons: `POST /v1/auth/validate`, which `authentication.md` names to
record that it was deliberately not ported, and `/v1/library`, the
deleted GraphQL schema named by the section recording its deletion. The
allowlist is keyed on `(file, citation)` rather than a line number so
reflowing a paragraph does not churn it, and an entry that no longer
matches any citation is itself a finding — an exemption nobody notices
has stopped being true is how a checker quietly stops checking.

The `docs-truth` paths filter grows to cover `openapi.json`, because
regenerating the server's surface can invalidate a citation without the
citing doc changing, and neither the `docs` nor the `rust` filter would
have caught that.
A design doc earns its keep by letting a reader grep for what it names.
Seventeen citations named modules that are not there, so seventeen times
the doc was a story rather than a map.

Six were simply wrong, and the tree says what they should be. Lifecycle
writes and device surfaces live under the server's `routes/` modules, not
under `upload::` and `auth::`; album lifecycle is `capsule-server::album`
and there has never been an `organization` module; and the MLS wrapper is
`crypto::authority::openmls_authority`, which `mls.md` and `module-map.md`
already said while `mls-resilience.md` and `versioning.md` said
`crypto::mls`. That last one is the terminology drift a single-owner rule
is supposed to prevent, surviving in the two docs nobody cross-read.

`api-surfaces.md` also mapped storage verification onto
`capsule-server::blob` when the module is `verify`. The check cannot catch
that — `blob` exists — which is worth stating plainly: this gate proves a
name resolves, never that the claim around it is true.

Four are designed and unbuilt, and those go in `planned-modules.txt` with
the reason and the slice rather than being quietly deleted or quietly
tolerated. It is a short list and it is the honest answer to "what has the
design committed to that nobody has built?" at the module layer:
`capsule-core::media`, `capsule-core::notify`, `capsule-core::import::camera`
and `capsule-server::federation`. An entry whose module has since been
built, or that no doc names any more, is itself a finding — the failure
mode of every allowlist is outliving its reason.

`AGENTS.md` called `capsule-core::media` "the retiring stack". It is the
opposite: it is the module that will consume Rawshift once Rawshift
stabilizes, and neither exists yet — Rawshift is a pinned submodule that
is not a workspace dependency, and nothing in Capsule decodes media
today. The line now says that, because "retiring" and "not yet written"
point a reader in opposite directions.

Resolution is strict on the way down and lenient at the leaf, which is
what takes the false-positive rate to zero here: a non-final segment must
be a real directory or file, but a final segment may be an item or a
`pub use` re-export found in the module's subtree.
`capsule-core::library::available_bytes` is a re-export, not a file, and a
resolver that demands a file reports a correct citation as broken.

`SLICES.md` is deliberately out of scope. It is a historical ledger: it
records that `capsule_core::media` *was* retired and that `capsule-api`
*used to* hold the upload module, and forcing it to name only live
modules would corrupt the record it exists to keep.
`module-map.md` opened with a table headed **Status** whose Server row
read `Quarantined — rebuild with Kynos`. `capsule-server` is 125 files
and 56,990 lines with 576 tests and a committed 59-operation contract,
and it is the largest crate in the workspace. The Rust SDK row said
`Regenerate with Spargen`; the SDK is 17,226 lines and its client is
generated at build time by the `build.rs` that row describes as future
work. The E2E section declared every case with a server or SDK leg
"suspended". None of that has been true for weeks.

The fix is not a more accurate status table. A map that keeps score goes
stale at the rate the code moves, and this repository took 221 commits
in August. The table is gone, and with it the Quarantined/Active
vocabulary: this doc now answers one question — which design doc owns a
module's contract, and what tier validates it — and points at
`SLICES.md` for whether a thing exists yet, so there is one place to
look and one place to update.

What replaces it is a crate roster that is complete for the first time.
`capsule-server`, `capsule-sdk` and `capsule-wire` were absent
altogether; `capsule-wire` is 528 lines that both the server and the SDK
depend on and no design doc had ever named it. The web, Swift, Kotlin,
Android and vision crates are listed too.

The server-module table was a plan and is now a description. It listed
seven modules, two of which (`shares`, `federation`) do not exist under
those names, and omitted thirteen that do — `attestation`, `directory`,
`discovery`, `enrollment`, `escrow`, `gc`, `limits`, `problem`,
`routes`, `scrub`, `serve`, `store`, `verify`. Each row now names the
doc that owns it, which is what makes the table checkable at all.

Three more corrections where the tree disagreed with the list:
`library::trash` is not a module (`trash_path` is a re-export in
`paths`), the import module list was missing seven of its members, and
`filesystem/server.md` routed storage verification to
`capsule-server::blob` when the module is `verify`.

Chromahash was still gated on "after its v1 release" in four places —
`module-map.md`, `metadata.md`, `import/pipeline.md` and
`development/architecture.md` — while `dependencies.md` and
`thumbnails.md` correctly pinned **0.7.1** and `Cargo.lock` has held
0.7.1 for some time. That is the Single Source of Truth rule failing in
the exact way it is meant to prevent: six docs stating one choice, two
of them current. All six now say 0.7.1.

And `capsule-core::media` is no longer described as "the retiring
stack". It is the module that will consume Rawshift once Rawshift
stabilizes — the opposite of retiring — and calling it both leaves a
reader unable to tell whether to write it or delete it.
Small diff, and the one in this branch a security reviewer should
actually read. Each of these cites a numbered validation invariant for a
property that invariant does not state.

**`provenance.md` cited invariant 17 for append-only enforcement.**
Invariant 17 is the chain-advance check: `prior_provenance_hash` equals
the last accepted manifest's content hash. It says nothing about
overwriting or deleting a provenance entry. Append-only is real, but it
is *derived* — 17 refuses a manifest that does not advance the chain,
and 16 closes the action enum, which contains no overwrite or delete
action for a provenance entry, so there is no surface to request one
through. Asserting it against a rule that does not say it means a
reviewer checking the claim finds the wrong text and either mistrusts
the doc or, worse, concludes the property is unprotected.

**`upload-protocol.md` called invariant 26 "the platform-wide
indistinguishable-404 rule".** It is not platform-wide: `federation.md`
scopes 26–32 to the drop path, and 26 is specifically upload-*link*
liveness. The platform-wide rule already had an owner —
`api-surfaces.md`'s rejection mapping — so the citation now points there,
and names 26 as the drop-path instance rather than the source.

**`validation.md` filed invariants 16–18 under "non-upload writes",
which made a correct doc look wrong.** `upload-protocol.md` applies 17
and 18 to a `replace` arriving over the upload protocol, and had to
spend a clause insisting it was doing what lifecycle writes do — because
the registry said those checks were for non-upload writes. The registry
was wrong: the three are grouped by *where they are enforced*, the
index's critical section, and that section is entered by a lifecycle
write and by a `replace` alike. The heading now says so. Enforcing them
at the gate instead would let two concurrent replaces both pass and
double-apply, reintroducing the stale revival 17 exists to catch, in the
code enforcing it.

**`federation.md` cited "all items 1–18" and then restated seven of
them.** An ordinal range goes stale the moment a check is added, and the
restatement is a second copy of a list that already has an owner. It now
cites the three sections by name. The exclusion is stated as a reason
rather than a range — the drop-path checks cannot arise on a pull
because a pull carries a manifest and a drop carries none — which stays
true however the list grows.

The underlying problem is that the numbering is one flat namespace
grouped by write phase, and the phase grouping is what produced all
three mis-scopings. Replacing it with owner-scoped identifiers is a
larger change and is not this commit; these four are wrong today and are
fixed today.
The documentation homepage advertised a demo at `demo.capsule.app` — a
domain that appears nowhere else in this repository and does not
resolve — from both the hero and a call-to-action band. It promised
"AI-powered object recognition", "face recognition", "automatic
synchronization", "easy self-hosting setup" and "no data limits: store
unlimited photos". There is no published client binary, no deployable
server, no inference runner, and `quota.md` is the design doc that owns
storage limits. It also carried a `{/* TODO: Finish this */}` and the
Astro starter template's mascot.

The screenshot was the sharpest version of the problem: `AppScreenshot`
rendered `/capsule-screenshot.webp` inside a browser-chrome frame, and
that file is thirty-one bytes reading `TODO: REPLACE THIS PLACEHOLDER`.
The homepage was showing a broken image in a window frame to anyone who
loaded it.

`design/principles.md` already forbids exactly this — "a doc describing
a planned surface writes the contract in normative present tense, but
must not claim the code exists" — and the splash page escaped it by
being the one page nobody thought of as a doc. It is the first page
anyone sees, so it is the page where the claim costs most.

The rewrite says what is true and what is not: a caution block states
plainly that nothing is usable yet and points at the design docs and at
local development, and the feature cards describe properties the design
actually commits to — the key-free server, self-hosting, sidecar-based
recoverability, originals kept as originals — each of which a reader can
follow into a doc that specifies it. The "why" section names the costs
rather than only the benefits, and links the three docs that state them.

`features/smart-albums.md` was six ML album types in the present tense —
people, trips, pets, food, scenes, best-of-year — for models that do not
exist. It now describes what a smart album *is*, which is a predicate
rather than a membership list, defers the grammar to `organization.md`
and the models to `ai.md`, and says the ML-dependent kinds depend on
them. That also fixes a gap the audit turned up: this page and the
predicate grammar had no link between them in either direction.

`AppScreenshot.astro`, `TryNowSection.astro` and the placeholder image
are removed rather than left unused. The second hardcoded the demo URL,
and a dead component carrying a false claim is a claim waiting to be
re-imported.
docs: audit the design docs against the code, and gate what they name
PR #396 merged a stale local copy of feat/v1-code-push. The 75 commits
that had already landed on its origin head — the Apple client (#388),
the docs-truth gates and ADRs (#395), the CI fixes for the wasm target,
and the i18n-guard computed-property detector — never reached master.

This merges origin/feat/v1-code-push. The one conflict, in the root
Cargo.toml, resolves to the v1-head side: it removes a strict superset
of the unused workspace dependencies master removed, and nothing outside
legacy-review references any of them.

Refs #397
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 99dd4bc
Status: ✅  Deploy successful!
Preview URL: https://9180d74d.capsule-22k.pages.dev
Branch Preview URL: https://chore-merge-v1-head-397.capsule-22k.pages.dev

View logs

The v1 head removed `@formatjs/cli` and its two `intl:*` scripts from
capsule-web/package.json without regenerating the lockfile, so
`bun install --frozen-lockfile` in the Web CI job fails with "lockfile
had changes, but lockfile is frozen" — on master (run 33572237092) and
on this branch (run 33574327583) alike. Regenerated with `bun install`;
no dependency versions move, the stale entry leaves.

Refs #397
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

master is 75 commits behind feat/v1-code-push: land the catch-up merge

1 participant