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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user