Align the identity predicate with the ratified shape-only rule

Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.

Full suite 342 tests in 63 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 20:53:38 -04:00
parent 1d7449e49a
commit 4418b7f981
6 changed files with 301 additions and 51 deletions
+38 -9
View File
@@ -7,16 +7,31 @@ import Foundation
/// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field
/// the three distinct Swift types encode it structurally.
/// The immutable identity of a lane or card folder: its exact name, byte-for-byte.
/// The immutable identity of a lane or card folder: its exact name, byte-for-byte compared as
/// a UUID *value*.
///
/// Folder names are lowercase UUIDv4, gated at load time (01-storage-format.md § Fractal
/// layout Rules, "Name shape gates level detection") `BoardLoader` only ever promotes a
/// UUID-*shaped* folder (lowercase hex, `8-4-4-4-12`; version/variant nibbles unchecked) to a
/// `Lane`/`Card` in the first place, so every `ItemID` reaching this type already has that
/// shape. The model still stores whatever the folder is actually named rather than
/// re-validating or normalizing it: it must round-trip byte-perfect (it is the primary key) and
/// is the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's
/// `UUID`, which normalizes to uppercase and would silently corrupt that round-trip.
/// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip
/// byte-perfect (it is the primary key on disk), and it is the display-order tie-break
/// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not**
/// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that
/// round-trip.
///
/// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal
/// layout Rules, settled): *two case-spellings of one UUID are one identity everywhere*
/// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by
/// this type. That matches default-APFS case-insensitivity, where the two spellings name one
/// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints
/// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` hex, `8-4-4-4-12`,
/// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so
/// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value
/// canonicalization. Nothing asserts that the type stays total on any string a caller hands it;
/// off-shape input simply compares by its own lowercasing, which is the harmless reading.
///
/// Consequences, all intended: `RawRepresentable` is unaffected only `==` and `hash(into:)`
/// are hand-written, and `rawValue` still reads back exactly as it was stored;
/// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder
/// is the *same* row rather than a delete plus an insert; and a `Set<ItemID>` holding both
/// spellings collapses them to one member, keeping whichever arrived first.
///
/// Board roots don't get one of these: a board's folder name is a human/Finder-assigned
/// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) its identity is
@@ -28,6 +43,20 @@ public struct ItemID: Hashable, Sendable, RawRepresentable {
public init(rawValue: String) {
self.rawValue = rawValue
}
/// The comparison key: `rawValue` case-folded. Computed rather than stored so `ItemID` stays
/// one string wide and `rawValue` remains the single source of truth for what is on disk.
/// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is
/// UUID-value canonicalization and nothing more.
var canonicalValue: String { rawValue.lowercased() }
public static func == (lhs: ItemID, rhs: ItemID) -> Bool {
lhs.canonicalValue == rhs.canonicalValue
}
public func hash(into hasher: inout Hasher) {
hasher.combine(canonicalValue)
}
}
extension ItemID: CustomStringConvertible {