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
+32 -16
View File
@@ -9,8 +9,9 @@ import os
///
/// Level is position: root `index.md` board, depth-1 folders lanes, depth-2 folders
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout
/// Rules): only a folder whose name has UUIDv4's shape lowercase hex, `8-4-4-4-12` is a
/// lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly what's checked.
/// Rules): only a folder whose name has a UUID's shape hex, `8-4-4-4-12`, **any case and any
/// version** is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly
/// what's checked.
/// Anything else even a directory holding a perfectly valid `index.md` is a stray: skipped
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
@@ -175,26 +176,40 @@ public enum BoardLoader: Sendable {
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
}
/// The lowercase hex characters `isUUIDShaped` accepts in each `-`-delimited group.
private static let lowercaseHexDigits = Set("0123456789abcdef")
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group **both cases**,
/// per the shape-only identity predicate below.
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
/// Whether `name` has UUIDv4's shape lowercase hex, `8-4-4-4-12` gating lane/card level
/// detection (01-storage-format.md § Fractal layout Rules, "Name shape gates level
/// detection"). Deliberately permissive about *which* nibbles matter: the version (13th hex
/// digit) and variant (17th hex digit) are **not** validated, so any lowercase-hex string in
/// this shape reads as a candidate whether or not it was actually minted by
/// `UUID().uuidString.lowercased()`. That reading is intentional, not an oversight: the
/// loader's job is recognizing the folder-naming *convention*, not re-deriving RFC 4122
/// conformance every load. Case-sensitive an uppercase or mixed-case UUID string is a
/// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name
/// (`BoardModel.swift`).
/// Whether `name` has a UUID's shape hex, `8-4-4-4-12`, **any case and any version**
/// gating lane/card level detection (01-storage-format.md § Fractal layout Rules, "Name
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
/// **shape-only**: lowercase v4 is the app's emission rule, not the gate.
///
/// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*,
/// so a strict lowercase gate would turn an agent's standard-tool card into a silently
/// skipped stray the worst failure mode for a files-first app. Accept liberally, emit
/// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an
/// existing folder to canonicalize it.
/// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are
/// **not** validated: they protect no invariant here an agent's v7 is exactly as unique
/// as a v4 and the loader's job is recognizing the folder-naming *convention*, not
/// re-deriving RFC 4122 conformance every load.
///
/// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md
/// states the rule; kept as a manual scan because that is the cheaper answer on the hot path
/// (every folder of every load) and needs no bridging.
///
/// Recognizing a name is not the same as *comparing* two of them: identity comparison is
/// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere
/// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares
/// canonically.
///
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
/// candidates the loader walked, and level detection has to be one rule, not two.
static func isUUIDShaped(_ name: String) -> Bool {
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) }
return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) }
}
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
@@ -310,7 +325,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
/// this case.
case missingIndex(path: String)
/// A lane/card-depth folder whose name doesn't have UUIDv4's shape (`isUUIDShaped`)
/// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` hex,
/// `8-4-4-4-12`, any case, any version)
/// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
/// or none at all (01-storage-format.md § Fractal layout Rules, "Name shape gates level
/// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
+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 {
+42 -14
View File
@@ -259,15 +259,19 @@ public enum BoardWriter: Sendable {
/// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` the folder-naming convention
/// itself (01-storage-format.md § Fractal layout Rules, "Folder names are lowercase
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design.
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission**
/// rule accept liberally, emit conservatively. The loader's gate
/// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the
/// app holds itself to, not something a reader depends on.
///
/// Two exclusions, both re-minted rather than assumed away: a name already on disk in
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
/// race with an existing entry), and any name in `taken` the identities a collision repair
/// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID
/// hitting either is astronomically unlikely 122 bits of randomness per mint but the
/// loop body is trivial precisely because the case it handles essentially never fires.
/// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
/// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value*
/// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
/// minted UUID hitting either is astronomically unlikely 122 bits of randomness per mint
/// but the loop body is trivial precisely because the case it handles essentially never fires.
private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
var name: String
repeat {
@@ -439,7 +443,10 @@ public enum BoardWriter: Sendable {
/// siblings while the moved item is still elsewhere and so cannot count itself.
/// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` +
/// `isUUIDShaped`; strays skipped, tombstones kept) but only on an import, since a
/// same-board move cannot collide with anything but itself.
/// same-board move cannot collide with anything but itself. The scan and every probe
/// against it are **by UUID value, not spelling** (`identities(inBoard:)` /
/// `canonicalIdentity`): an arriving `55555555-` collides with a resident `55555555-`
/// spelled in uppercase, because those are one identity (§ Fractal layout Rules).
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
/// rather than moving and then renaming: one filesystem operation instead of two, and it
@@ -492,7 +499,15 @@ public enum BoardWriter: Sendable {
rank = order
} else {
let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false)
rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order))
// "Which sibling is the item itself" is an identity question, so it is asked by
// UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
// directory listing can disagree in case for one and the same folder.
let selfIdentity = canonicalIdentity(sourceName)
rank = Ranks.append(
toVisible: siblings
.filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
.map(\.order)
)
}
try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
@@ -508,7 +523,7 @@ public enum BoardWriter: Sendable {
var reminted: [MoveResult.Remint] = []
var arrivedName = sourceName
if existing.contains(sourceName) {
if existing.contains(canonicalIdentity(sourceName)) {
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
reserved.insert(arrivedName)
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
@@ -527,8 +542,8 @@ public enum BoardWriter: Sendable {
if isImport {
let children = childCandidates(of: arrivedRoot)
reserved.formUnion(children.map(\.lastPathComponent))
for child in children where existing.contains(child.lastPathComponent) {
reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) })
for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) {
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
reserved.insert(fresh)
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
@@ -561,17 +576,30 @@ public enum BoardWriter: Sendable {
/// and the conservative direction here a missed identity remints nothing, and a duplicate
/// UUID in one board is the unspecified-behavior case the design already names, not a
/// corruption.
/// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`),
/// and every probe against the returned set must be too. Identity comparison is UUID-*value*
/// equality, never string equality (§ Fractal layout Rules, settled) an arriving
/// `55555555-` and a resident `55555555-` spelled uppercase are **one** identity, and a
/// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
private static func identities(inBoard boardRoot: URL) -> Set<String> {
var identities: Set<String> = []
for lane in childCandidates(of: boardRoot) {
identities.insert(lane.lastPathComponent)
identities.insert(canonicalIdentity(lane.lastPathComponent))
for card in childCandidates(of: lane) {
identities.insert(card.lastPathComponent)
identities.insert(canonicalIdentity(card.lastPathComponent))
}
}
return identities
}
/// A folder name reduced to its identity *value* the same canonicalization `ItemID`'s
/// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names
/// as strings because it is working with paths rather than model values. Every identity-shaped
/// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly.
private static func canonicalIdentity(_ folderName: String) -> String {
folderName.lowercased()
}
/// A folder's UUID-shaped subfolders in deterministic order `directoryCandidates` (hidden
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
/// level-detection rule and therefore the only definition of "an identity-bearing child"
@@ -1017,8 +1045,8 @@ public enum BoardWriter: Sendable {
}
/// Refuses a folder that is not a lane or a card. Level detection is by name shape
/// (01-storage-format.md § Fractal layout Rules), so a stray `notes/`, an uppercase
/// UUID, a hand-made folder is not an item, and moving, copying, deleting, restoring, or
/// (01-storage-format.md § Fractal layout Rules), so a stray `notes/`, a truncated or
/// non-hex UUID, a hand-made folder is not an item, and moving, copying, deleting, restoring, or
/// purging one as if it were would invent (or destroy) an identity the loader would
/// otherwise just ignore. Shared by every operation that must never reach a board root: a
/// board root's folder name is never UUID-shaped (§ Board naming), so this one check is