Realign code with the 2026-07-29 findings-resolution rulings
Nine rulings land as code. Reorders don't stamp — one container-change predicate (WriteOperation.rewritesOrderOnly): within-container reorders and the renumber rescale rewrite only order, while cross-lane, cross-board, and trash moves stamp modified and clear modified-by; no trash special case exists, and the m8 undo inverses conform through the same seam. Copies are transactions: the root-strict/nested-lenient split retires for a whole-subtree stampability preflight that refuses loudly naming the offender, and every item-level copy severs remote/remote-state at every level (whole-board forks carry them verbatim). Paste refuses, never degrades: the embedded-index.md materialization and its loss row retire; a missing staged snapshot produces nothing and posts an error-tone one-shot named from manifest metadata. Coerce-tier fallbacks log through the Defect stream with path context attached loader-side. Displacement is level-uniform: a file squatting attachments inside a card heals by the same rename ladder as board-root squatters; comments stays tolerated. Delete Immediately joins card and lane context menus as Delete's ⌥-alternate with its own VO custom action, routed through an explicit container so the menu target outranks standing selection. Agent guide v7 teaches the stamp discipline and the card-level attachments claim, and sheds two stale v6 lines (lanes trash now; kind is taught). Verified conformant, unchanged: edition-aware Undo/Redo disable, trash marquee full-height backdrop. Both schemes 1854 tests / 318 suites green; verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -55,8 +55,16 @@ enum AgentGuide {
|
||||
/// pointer) and supersedes it on the next open. v6 adds the one-folder-at-a-time move warning:
|
||||
/// a real agent incident (2026-07-29) showed `mv <lane>/*` sweeping the lane's own `index.md`
|
||||
/// along with the cards and destroying the destination lane's identity — the guide now says
|
||||
/// *why* the named-folder form is load-bearing, not just what to type.
|
||||
static let version = 6
|
||||
/// *why* the named-folder form is load-bearing, not just what to type. v7 teaches two more
|
||||
/// things settled after v6 shipped: the refined stamp-discipline predicate
|
||||
/// (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29, refined 2026-07-30) — a
|
||||
/// within-container reorder rewrites only `order`, while a move that changes an item's
|
||||
/// container (another lane, another board, into or out of `.trash/`) stamps `modified` and
|
||||
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
|
||||
/// not a special case — and the card-level `attachments` claimed name
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, "level-uniform"): that name belongs to the
|
||||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight.
|
||||
static let version = 7
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -325,7 +333,7 @@ enum AgentGuide {
|
||||
<board>/ this folder (the board)
|
||||
├── index.md board title + settings; body = board description
|
||||
├── CLAUDE.md this guide (app-maintained)
|
||||
├── .trash/ deleted cards (app-managed — see Deleting)
|
||||
├── .trash/ deleted cards and lanes (app-managed — see Deleting)
|
||||
├── <uuid>/ a LANE
|
||||
│ ├── index.md lane title + order; body = lane notes/policy
|
||||
│ ├── <uuid>/ a CARD
|
||||
@@ -350,7 +358,7 @@ enum AgentGuide {
|
||||
- Lane titles carry the workflow semantics (e.g. To Do → In Progress →
|
||||
Done). Read the board's and lanes' index.md bodies for descriptions and
|
||||
per-lane policy before deciding where a card belongs.
|
||||
- `.trash/` holds deleted cards; everything else at board root that isn't a
|
||||
- `.trash/` holds deleted cards and lanes; everything else at board root that isn't a
|
||||
UUID-named folder is not part of the board's content.
|
||||
|
||||
## Frontmatter
|
||||
@@ -397,6 +405,7 @@ enum AgentGuide {
|
||||
```markdown
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
title: Short imperative card title
|
||||
order: 3072
|
||||
created: 2026-07-24T18:00:00Z
|
||||
@@ -406,11 +415,24 @@ enum AgentGuide {
|
||||
The card's content — any Markdown.
|
||||
```
|
||||
|
||||
Creating a lane is the same one level up (body optional; `order` ranks
|
||||
lanes left→right).
|
||||
Creating a lane is the same one level up (body optional; `kind: lane`;
|
||||
`order` ranks lanes left→right).
|
||||
|
||||
**Always write `kind`** at creation — `kind: card`, `kind: lane`,
|
||||
`kind: board` at board root. Depth already says what an item is on the
|
||||
board, but `.trash/` is flat, and there the value is the only thing that
|
||||
tells a trashed lane from a card. Omitting it is healable, never fatal: the
|
||||
app fills a missing `kind` in the next time it rewrites that file.
|
||||
|
||||
## Moving and reordering
|
||||
|
||||
- **The stamp rule**: a move that changes an item's container — another
|
||||
lane, another board, or into/out of `.trash/` — stamps `modified` and
|
||||
re-stamps `modified-by`, the same as any content edit. A reorder that
|
||||
keeps an item in the same container (a card among its lane's cards, a
|
||||
lane among the board's) rewrites only `order`; leave `modified` and
|
||||
`modified-by` alone. The trash move isn't an exception to this — it
|
||||
stamps because every container change stamps.
|
||||
- Move to another lane: `mv <laneA>/<card-uuid> <laneB>/` — the folder move
|
||||
IS the move. Then set the card's `order` to place it among the
|
||||
destination's cards, update `modified`, and re-stamp `modified-by`.
|
||||
@@ -418,28 +440,39 @@ enum AgentGuide {
|
||||
A lane folder holds its own `index.md` beside its cards, so a glob
|
||||
sweeps the lane's identity file along with them and overwrites the
|
||||
destination lane's.
|
||||
- Reorder within a lane: rewrite only that card's `order`.
|
||||
- Reorder within a lane: rewrite only that card's `order` — don't touch
|
||||
`modified` or `modified-by`.
|
||||
|
||||
## Editing and deleting
|
||||
|
||||
- Edit bodies freely; update `modified` on every write. Preserve frontmatter
|
||||
keys you don't recognize and don't reformat content you didn't change.
|
||||
- **Delete a card = move it into `<board>/.trash/`**: `mv <lane>/<card-uuid>
|
||||
<board>/.trash/` (create `.trash/` if missing). Arrivals go on top: set
|
||||
the card's `order` to the smallest `order` already in `.trash/` minus
|
||||
1024 (empty trash: any number), and update `modified`. Restore is the
|
||||
same move in reverse — into a lane, with a fresh `order`.
|
||||
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
|
||||
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
|
||||
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
|
||||
travels with its cards inside it. Arrivals go on top: set the moved
|
||||
item's `order` to the smallest `order` already in `.trash/` minus 1024
|
||||
(empty trash: any number). It's a container change like any other move
|
||||
(Moving and reordering above): stamp `modified` and re-stamp
|
||||
`modified-by`. Restore is the same move in reverse — a card into a lane,
|
||||
a lane back to board root, with a fresh `order`, stamped the same way.
|
||||
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
|
||||
flat, so an empty lane folder looks exactly like a card folder; the `kind`
|
||||
value is what tells them apart in there.
|
||||
- Never write a `deleted:` key — that convention is retired; the app
|
||||
migrates any it finds.
|
||||
- Remove a folder outright (`rm -r`) only when you mean permanent,
|
||||
unrecoverable deletion. Lanes have no trash: deleting a lane folder is
|
||||
permanent, so be sure.
|
||||
unrecoverable deletion — the trash is the recoverable path for both
|
||||
cards and lanes.
|
||||
|
||||
## Attachments
|
||||
|
||||
- A card's files live in `attachments/` inside the card folder, flat at its
|
||||
top level. **Put files there, never beside `index.md`** — the app
|
||||
relocates loose files into `attachments/` and tells the user it did.
|
||||
The name `attachments` itself belongs to that folder — never create a
|
||||
*file* called `attachments` in a card; the app treats one as a defect
|
||||
and displaces it on sight.
|
||||
- To attach a file: create `attachments/` if missing and copy the file in.
|
||||
If the name is taken, pick a free one Finder-style (`shot.png` →
|
||||
`shot 2.png`) — never overwrite.
|
||||
|
||||
@@ -168,6 +168,27 @@ public enum BoardLoader: Sendable {
|
||||
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
|
||||
var defects: [IntegrityRules.Defect] = []
|
||||
|
||||
// **The coerce tier's trace** (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
||||
// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries
|
||||
// in the integrity service's Defect stream"). Called once per document this walk parses, at
|
||||
// every level, because the rule is about *fields* and every level has them — and called here
|
||||
// rather than inside `readDocument` for the reason the whole defect stream lives in `load`: the
|
||||
// reading functions are pure and total, and the walk is what owns what it found.
|
||||
//
|
||||
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
|
||||
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
|
||||
// change").
|
||||
func noteCoercions(in document: FrontmatterDocument, at path: String) {
|
||||
let fields = document.coercedFields
|
||||
guard !fields.isEmpty else { return }
|
||||
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
|
||||
for field in fields {
|
||||
logger.info(
|
||||
"\(path, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
||||
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
||||
// the displacement is the store's, through the Writer (the Repair precedent).
|
||||
@@ -187,6 +208,8 @@ public enum BoardLoader: Sendable {
|
||||
warn(.boardLevelDeletedIgnored)
|
||||
}
|
||||
|
||||
noteCoercions(in: boardDocument, at: indexFileName)
|
||||
|
||||
// Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
|
||||
// identity dedupe below decides which folders render at all, and a `Lane` is built only on
|
||||
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
||||
@@ -211,6 +234,7 @@ public enum BoardLoader: Sendable {
|
||||
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
||||
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
|
||||
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
|
||||
noteCoercions(in: laneDocument, at: lanePath)
|
||||
|
||||
var cards: [Card] = []
|
||||
for cardURL in try directoryCandidates(in: laneURL) {
|
||||
@@ -226,6 +250,20 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
|
||||
|
||||
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
|
||||
// `attachments` breaks every import into this card, every Finder drop on it and the
|
||||
// window's listing for as long as it stands, so it is scheduled work exactly as a
|
||||
// squatted `.trash` is. Detection is read-only here, like every other defect; the
|
||||
// displacement is the store's, through the Writer.
|
||||
for squatter in IntegrityRules.squattedClaimedNames(inCardAt: cardURL, path: cardRelPath) {
|
||||
defects.append(.claimedNameSquatted(squatter))
|
||||
logger.warning(
|
||||
"\(cardRelPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
||||
)
|
||||
}
|
||||
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
@@ -287,6 +325,7 @@ public enum BoardLoader: Sendable {
|
||||
continue
|
||||
}
|
||||
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
||||
noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName)
|
||||
// **The trash's discriminator, applied where the flat container needs it**
|
||||
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||
// outright, and only an unrecognized value or no key at all falls through to shape.
|
||||
@@ -944,6 +983,17 @@ public struct LoadResult: Sendable {
|
||||
public var duplicateIdentities: [DuplicateIdentity] {
|
||||
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// Every file whose lenient fields fell back to their defaults — a **view over `defects`**
|
||||
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29).
|
||||
///
|
||||
/// **Nothing consumes it, and that is the point**: the coerce tier changes no behavior, so this is
|
||||
/// the observability handle — a suite pins it, a developer reads the log lines it produced, and the
|
||||
/// day a shape shows up often enough to deserve a heuristic heal, this is where the evidence
|
||||
/// already is. In walk order: the board, then each lane and its cards, then the trash.
|
||||
public var coercedFrontmatter: [CoercedFrontmatter] {
|
||||
defects.compactMap { if case let .coercedFrontmatter(work) = $0 { work } else { nil } }
|
||||
}
|
||||
}
|
||||
|
||||
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
|
||||
|
||||
+274
-115
@@ -17,8 +17,10 @@ import Foundation
|
||||
/// and the body survive every write by construction rather than by remembering to preserve
|
||||
/// them.
|
||||
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
|
||||
/// path. Absence of `modified-by` means "the board's user, via the app"; the file is being
|
||||
/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing.
|
||||
/// path that rewrites *content*. Absence of `modified-by` means "the board's user, via the app";
|
||||
/// the file is being rewritten anyway, so clearing an external writer's self-reported stamp costs
|
||||
/// nothing. The one class of write that does neither is the **order-only rewrite**
|
||||
/// (`WriteOperation.rewritesOrderOnly` — the reorders-don't-stamp rule).
|
||||
/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict
|
||||
/// UTF-8, and a file that does not decode is a loud, specific error rather than a
|
||||
/// lossy best guess.
|
||||
@@ -44,6 +46,26 @@ public enum BoardWriter: Sendable {
|
||||
/// two keys, and no call site has to remember them.
|
||||
/// 4. **Atomic replace.**
|
||||
///
|
||||
/// ## The reorders-don't-stamp predicate
|
||||
///
|
||||
/// Step 3 is **skipped for an order-only rewrite** (01-storage-format.md § Frontmatter ▸
|
||||
/// `modified`'s scope — ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30 to the
|
||||
/// container-change predicate). `order` is logically the *container's* property — a relationship
|
||||
/// among a lane's members that the format happens to store inside each member's file — so a
|
||||
/// rewrite that only restates position touches no content: no `modified` stamp, and no
|
||||
/// `modified-by` clear (the two are paired; attribution cannot change when content didn't).
|
||||
///
|
||||
/// **The predicate is the operation's, not a parameter** (`WriteOperation.rewritesOrderOnly`):
|
||||
/// the vocabulary already draws the line this rule needs — `.reorder` is by construction the
|
||||
/// same-container case (`moveItem` decides it from the two URLs before it touches disk) and
|
||||
/// `.renumberChildren` is the whole-lane rescale. Deriving it here rather than asking each call
|
||||
/// site means no caller can forget, and the rule stays one exhaustive switch a suite can pin
|
||||
/// without a filesystem.
|
||||
///
|
||||
/// **There is no trash branch anywhere**, deliberately: a move into or out of `.trash/` changes
|
||||
/// the item's container, so it stamps for the same reason a cross-lane move does. The trash move
|
||||
/// is the container rule's plainest instance rather than an exception to a rule about moves.
|
||||
///
|
||||
/// The **one path that deliberately bypasses this** is the card window's raw-source Apply
|
||||
/// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
|
||||
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
|
||||
@@ -90,8 +112,13 @@ public enum BoardWriter: Sendable {
|
||||
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
||||
// which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
// The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
|
||||
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
||||
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
||||
if !operation.rewritesOrderOnly {
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
}
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
@@ -403,6 +430,12 @@ public enum BoardWriter: Sendable {
|
||||
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
|
||||
/// ascending, folder name breaking ties) — the same rule the loader sorts by, so a
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
|
||||
/// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
|
||||
/// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
|
||||
/// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
|
||||
/// `.renumberChildren` answering `rewritesOrderOnly` rather than being arranged here — which is
|
||||
/// what keeps a whole lane's worth of bookkeeping from looking like a whole lane's worth of
|
||||
/// edits to the card window, to a future auto-purge, and to an agent's own attribution.
|
||||
///
|
||||
/// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
|
||||
/// siblings renumbered and some not — every `order` still a valid float, display order
|
||||
@@ -498,10 +531,17 @@ public enum BoardWriter: Sendable {
|
||||
/// construction rather than by copying carefully.
|
||||
///
|
||||
/// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new
|
||||
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and
|
||||
/// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank
|
||||
/// (a drop between two siblings), or `nil` to append after the destination's visible
|
||||
/// siblings.
|
||||
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"). `order` is the
|
||||
/// caller's explicit rank (a drop between two siblings), or `nil` to append after the
|
||||
/// destination's visible siblings.
|
||||
///
|
||||
/// **Whether that rewrite stamps is the container question** (§ Frontmatter ▸ `modified`'s scope,
|
||||
/// refined 2026-07-30 — `WriteOperation.rewritesOrderOnly`), and this call is where it is
|
||||
/// answered for every move in the app: the same-parent degenerate path below is a `.reorder` and
|
||||
/// rewrites `order` alone, while a real move — cross-lane, cross-board, into or out of `.trash/`
|
||||
/// — is a `.move` and stamps `modified` and clears `modified-by` like any content write. The
|
||||
/// branch that already exists for the *rank arithmetic* is therefore the whole of the stamping
|
||||
/// rule too; there is no second test, and pointedly no trash case.
|
||||
///
|
||||
/// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
|
||||
/// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
|
||||
@@ -762,23 +802,36 @@ public enum BoardWriter: Sendable {
|
||||
/// `Date` for the whole tree: a card made from a template is born today, not forked from
|
||||
/// the template (09-templates.md).
|
||||
///
|
||||
/// Two deliberate leniencies below the root, both of them "what a hand copy would do":
|
||||
/// Plus the tracker sever, which is the copy contract's third clause: every folder this
|
||||
/// materializes drops the reserved `remote`/`remote-state` keys, at every level
|
||||
/// (`applyCopyContract`).
|
||||
///
|
||||
/// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot
|
||||
/// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy
|
||||
/// because one nested card is a flow mapping would be hostile, and the file arrives
|
||||
/// *exactly* as it was rather than corrupted — its stale `modified-by` attribution
|
||||
/// surviving is the self-reported-provenance honest limit § Frontmatter already
|
||||
/// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its
|
||||
/// new `order`), so an unreadable or uneditable root refuses the copy up front, before
|
||||
/// anything is materialized.
|
||||
/// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is
|
||||
/// copied and reminted like any other, and not rewritten: the same skip the loader applies
|
||||
/// to it (`.missingIndex`).
|
||||
/// ## A copy is a transaction (ruled 2026-07-29)
|
||||
///
|
||||
/// **The whole subtree is preflighted for stampability before anything is materialized**, and a
|
||||
/// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card
|
||||
/// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md` …
|
||||
/// preflights the entire subtree and refuses whole, loudly, naming the offending item — never a
|
||||
/// partial copy, never a silently unstamped descendant").
|
||||
///
|
||||
/// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or
|
||||
/// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The
|
||||
/// leniency read as kindness and was in fact the one verdict 01's doctrine forbids: "leniency is
|
||||
/// recovering recoverable issues through reliable heuristics — never accepting loss that could
|
||||
/// surprise the user … 'proceed partially, lose a little' is never a verdict". A silently
|
||||
/// unstamped descendant carries a stale `modified-by` and, since 2026-07-29, a *live tracker
|
||||
/// claim* into a second local object — which is exactly the surprise. The finest-grain precedent
|
||||
/// covers identity collisions (a per-folder repair that loses nothing), not skipped contract work.
|
||||
///
|
||||
/// The preflight runs over the **source**, so a refusal costs nothing on disk: nothing is copied,
|
||||
/// nothing is renamed, nothing has to be cleaned up. A folder with **no `index.md`** —
|
||||
/// interrupted-create residue — is not an offense: it is copied and reminted like any other and
|
||||
/// simply has nothing to stamp, the same skip the loader applies to it (`.missingIndex`).
|
||||
///
|
||||
/// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
|
||||
/// removes the partially copied tree best-effort and rethrows, because a half-copied item is
|
||||
/// pure residue — nothing was there before, so there is no true state for a reload to show.
|
||||
/// pure residue — nothing was there before, so there is no true state for a reload to show. After
|
||||
/// a clean preflight the only thing left to fail is disk, and that is what this catch is for.
|
||||
/// The source is never touched on any path.
|
||||
public static func copyItem(
|
||||
at sourceFolder: URL,
|
||||
@@ -794,6 +847,9 @@ public enum BoardWriter: Sendable {
|
||||
// failure from here on in this call — including inside the materialized-but-not-yet-
|
||||
// stamped tree below — reuses the enriched value.
|
||||
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
|
||||
// The transaction's preflight, over the *source*: a subtree that cannot honor the copy
|
||||
// contract refuses here, where nothing has been materialized and there is nothing to undo.
|
||||
try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)
|
||||
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
@@ -816,9 +872,7 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
let now = Date()
|
||||
try updateIndex(inItemFolder: root, operation: operation) { document in
|
||||
if case .born = stamps {
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
}
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
for folder in copied {
|
||||
@@ -864,13 +918,102 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a
|
||||
/// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather
|
||||
/// than failing the gesture. `order` is not touched: a nested item keeps its rank among its
|
||||
/// own siblings, which travelled with it.
|
||||
/// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
|
||||
/// materializes — root and descendants alike, in one place so the two can never disagree about
|
||||
/// what a copy owes.
|
||||
///
|
||||
/// Two clauses, and `updateIndex` adds the third:
|
||||
///
|
||||
/// - **`created` per `stamps`** — `.fork` keeps it (a copy really was created when its original
|
||||
/// was), `.born` restamps it, because a board or card made from a template is born today
|
||||
/// (09-templates.md).
|
||||
/// - **The reserved tracker keys go** — `remote` and `remote-state`, at every level
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "**Item-level copies sever
|
||||
/// tracker identity** … because two local objects must never both claim to be the same remote
|
||||
/// object"). Content preserved, mapping severed. Nothing reads the keys until Teams, and that is
|
||||
/// the argument rather than an objection: the copies made today are the boards Teams will meet,
|
||||
/// so the sever costs nothing now and spares a stale double-claim later. `FrontmatterDocument.remove`
|
||||
/// takes every occurrence, so a hand-duplicated key cannot leave a twin behind to resurrect the
|
||||
/// claim.
|
||||
/// - **`modified` stamped and `modified-by` cleared** come from `updateIndex`, because a copy is an
|
||||
/// app write like any other (§ Frontmatter) — not something this function has to remember.
|
||||
///
|
||||
/// `order` is deliberately absent: the copied *root* takes its new rank from its caller, and a
|
||||
/// nested item keeps its rank among its own siblings, which travelled with it.
|
||||
///
|
||||
/// **Whole-board forks do not call this at all** — Duplicate and Save as Template carry bytes
|
||||
/// verbatim, GUIDs, timestamps and tracker keys included (01 ▸ Identity lifecycle's carve-out).
|
||||
static func applyCopyContract(
|
||||
to document: inout FrontmatterDocument,
|
||||
stamps: CopyStamps,
|
||||
now: Date
|
||||
) {
|
||||
if case .born = stamps {
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
}
|
||||
document.remove(FrontmatterKeys.remote)
|
||||
document.remove(FrontmatterKeys.remoteState)
|
||||
}
|
||||
|
||||
/// **The copy transaction's preflight**: every identity-bearing folder beneath `folder` whose
|
||||
/// `index.md` the copy contract will rewrite, checked for readability and editability *before*
|
||||
/// anything is materialized — and the first offender refuses the whole copy, named
|
||||
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29: "preflights the entire subtree and
|
||||
/// refuses whole, loudly, naming the offending item").
|
||||
///
|
||||
/// **Naming the offender is the point**, so the thrown error is re-enriched with *that* item's
|
||||
/// title rather than the copy root's: a refusal reading "Couldn't copy 'Sprint 12'" when the
|
||||
/// unwritable file is one card inside it would send the user looking in the wrong place. A file
|
||||
/// that cannot be read at all has no title to offer, and its path — which the error always
|
||||
/// carries — is then the whole of what can honestly be said about it.
|
||||
///
|
||||
/// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
|
||||
/// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. The
|
||||
/// walk is `identityDescendants`', which is `remintDescendants`' own reach — so the set checked
|
||||
/// here is exactly the set that will be stamped, never a superset that could refuse a copy over a
|
||||
/// file nobody was going to touch.
|
||||
///
|
||||
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
|
||||
/// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
|
||||
static func checkCopiedDescendantsAreStampable(
|
||||
of folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
for descendant in identityDescendants(of: folder) {
|
||||
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
||||
let document = try readDocument(at: indexURL, operation: operation)
|
||||
try checkEditable(document, at: indexURL, operation: operation.withTitle(document.title.value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every identity-bearing folder beneath `folder`, depth first — `remintDescendants`' walk with
|
||||
/// the renaming taken out, so the preflight and the remint can never disagree about which folders
|
||||
/// a copy materializes as items.
|
||||
private static func identityDescendants(of folder: URL) -> [URL] {
|
||||
var found: [URL] = []
|
||||
for child in childCandidates(of: folder) {
|
||||
found.append(child)
|
||||
found.append(contentsOf: identityDescendants(of: child))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared
|
||||
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
|
||||
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
|
||||
/// copy like any other mid-flight failure (whose partial result the caller removes wholesale).
|
||||
///
|
||||
/// The former best-effort posture — copy it verbatim, skip its stamp — is retired with the
|
||||
/// root-strict/nested-lenient split (see `copyItem`): a silently unstamped descendant is a
|
||||
/// descendant still carrying somebody else's `modified-by` and, since 2026-07-29, somebody else's
|
||||
/// tracker claim.
|
||||
///
|
||||
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
|
||||
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
|
||||
///
|
||||
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
|
||||
/// instantiated board's lanes and cards are stamped by this exact rule, leniency included.
|
||||
/// instantiated board's lanes and cards are stamped by this exact rule.
|
||||
static func stampCopiedDescendant(
|
||||
at folder: URL,
|
||||
stamps: CopyStamps,
|
||||
@@ -878,85 +1021,13 @@ public enum BoardWriter: Sendable {
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
||||
let copied = try? readDocument(at: indexURL, operation: operation),
|
||||
copied.uneditableShape == nil
|
||||
else { return }
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { return }
|
||||
|
||||
try updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||
if case .born = stamps {
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
}
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk —
|
||||
/// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged
|
||||
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
|
||||
/// `index.md` — content intact, attachments absent").
|
||||
///
|
||||
/// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured
|
||||
/// verbatim at copy time and travels through the manifest untouched, so this call writes it as
|
||||
/// given — unknown keys, comments, blank lines, line endings, body and all — rather than
|
||||
/// re-serializing anything. That is the round-trip guarantee applied to a file the app is
|
||||
/// minting from bytes it was handed (01-storage-format.md § Fractal layout ▸ Rules).
|
||||
///
|
||||
/// **Fresh identity, fork stamps** — the same semantics `copyItem` gives an ordinary copy, and
|
||||
/// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint,
|
||||
/// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and
|
||||
/// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`.
|
||||
///
|
||||
/// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the
|
||||
/// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank
|
||||
/// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none.
|
||||
///
|
||||
/// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be
|
||||
/// rewritten — it needs its new `order` — so unparseable or uneditable text fails the call;
|
||||
/// a child is never rewritten, so whatever it is arrives exactly as it was.
|
||||
///
|
||||
/// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the
|
||||
/// folder exists removes the partial tree best-effort and rethrows, because a half-materialized
|
||||
/// item is pure residue — nothing was there before.
|
||||
public static func materializeItem(
|
||||
inParent destinationParent: URL,
|
||||
indexText: String,
|
||||
children: [String] = [],
|
||||
order: Double?
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
let operation = WriteOperation.copy(title: nil)
|
||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
// The same mint the create path uses, so every identity this app materializes is materialized
|
||||
// one way — fresh lowercase UUIDv4, folder and all.
|
||||
let root = try mintUUIDFolder(in: destinationParent, operation: operation)
|
||||
|
||||
do throws(BoardWriteError) {
|
||||
try atomicReplace(
|
||||
text: indexText,
|
||||
at: root.appendingPathComponent(BoardLoader.indexFileName),
|
||||
operation: operation
|
||||
)
|
||||
for child in children {
|
||||
let childFolder = try mintUUIDFolder(in: root, operation: operation)
|
||||
try atomicReplace(
|
||||
text: child,
|
||||
at: childFolder.appendingPathComponent(BoardLoader.indexFileName),
|
||||
operation: operation
|
||||
)
|
||||
}
|
||||
try updateIndex(inItemFolder: root, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw error
|
||||
}
|
||||
|
||||
return ItemID(rawValue: root.lastPathComponent)
|
||||
}
|
||||
|
||||
// MARK: - The materialized trash
|
||||
|
||||
/// `<boardRoot>/.trash/` — the board's trash container, named but not created.
|
||||
@@ -994,10 +1065,13 @@ public enum BoardWriter: Sendable {
|
||||
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
|
||||
/// the seam: the Writer takes a rank, the store computes it.
|
||||
///
|
||||
/// **The `modified` stamp is the point, not a side effect.** Deletion is the one exception to
|
||||
/// moves-don't-stamp — "deletion is an edit to the card's story" — and the stamp is what a
|
||||
/// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being
|
||||
/// asked for, which is why there is nothing extra in step 5.
|
||||
/// **The `modified` stamp is the point, not a side effect** — and it needs no exception to earn
|
||||
/// it. The trash move changes the card's *container*, which is the whole predicate
|
||||
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
|
||||
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
|
||||
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
|
||||
/// which is why there is nothing extra in step 5 — and why the reorders-don't-stamp rule needs no
|
||||
/// trash carve-out to coexist with this call.
|
||||
///
|
||||
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
|
||||
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
|
||||
@@ -1358,8 +1432,8 @@ public enum BoardWriter: Sendable {
|
||||
/// trees.
|
||||
/// - **The bytes are written verbatim** — nothing is stamped, nothing is re-serialized, no
|
||||
/// `index.md` is parsed. This replays; it does not edit.
|
||||
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the
|
||||
/// `materializeItem` rule — a half-restored lane is pure residue, since nothing was there.
|
||||
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
|
||||
/// `copyItem`'s rule — a half-restored lane is pure residue, since nothing was there.
|
||||
///
|
||||
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
|
||||
/// passes the same URL, and the snapshot's name is carried for identification, not as an
|
||||
@@ -1533,8 +1607,8 @@ public enum BoardWriter: Sendable {
|
||||
/// Puts a removed item's folder back, at its own path and with its own bytes — the redo half of
|
||||
/// an undone create (13-native-undo.md ▸ Rules).
|
||||
///
|
||||
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and
|
||||
/// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them
|
||||
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID, so they cannot
|
||||
/// replay a create: redoing through them
|
||||
/// would produce a *different* item, and every step registered above this one on the stack
|
||||
/// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
|
||||
/// path as given.
|
||||
@@ -1577,7 +1651,7 @@ public enum BoardWriter: Sendable {
|
||||
operation: operation
|
||||
)
|
||||
} catch {
|
||||
// `materializeItem`'s all-or-nothing rule: a half-made folder is pure residue, since
|
||||
// `copyItem`'s all-or-nothing rule: a half-made folder is pure residue, since
|
||||
// nothing was there before.
|
||||
try? FileManager.default.removeItem(at: itemFolder)
|
||||
throw error
|
||||
@@ -2178,6 +2252,14 @@ public enum BoardWriter: Sendable {
|
||||
/// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
|
||||
/// write discipline decides and there is no rule to add (§ Validation and healing).
|
||||
///
|
||||
/// **Level-uniform** (extended 2026-07-29): the squatter's own `location` names the folder the
|
||||
/// claimed name lives in — the board root, or a card's folder for a file wearing `attachments`. That
|
||||
/// is the whole of the difference, which is the point of the ruling: one ladder, one notice, one
|
||||
/// write, whichever level the name is claimed at. A card whose folder has since gone takes the
|
||||
/// re-verification's `nil` path like any other vanished defect.
|
||||
///
|
||||
/// - Parameter root: the board root. The squatter's location is resolved against it, so a board
|
||||
/// renamed since the load heals at its new location.
|
||||
/// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
|
||||
@discardableResult
|
||||
public static func displaceClaimedName(
|
||||
@@ -2185,13 +2267,14 @@ public enum BoardWriter: Sendable {
|
||||
atBoardRoot root: URL
|
||||
) throws(BoardWriteError) -> String? {
|
||||
let operation = WriteOperation.displaceClaimedName(name: squatter.name)
|
||||
let occupied = root.appendingPathComponent(squatter.name)
|
||||
let container = squatter.location.folder(under: root)
|
||||
let occupied = container.appendingPathComponent(squatter.name)
|
||||
guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let freed = freshName(for: squatter.name, in: root)
|
||||
let destination = root.appendingPathComponent(freed)
|
||||
let freed = freshName(for: squatter.name, in: container)
|
||||
let destination = container.appendingPathComponent(freed)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: occupied, to: destination)
|
||||
} catch {
|
||||
@@ -2565,6 +2648,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case move(title: String?)
|
||||
case reorder(title: String?)
|
||||
case copy(title: String?)
|
||||
|
||||
/// ⌘V — **a paste that refused before it wrote anything** (04-interactions.md ▸ Clipboard,
|
||||
/// re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses loudly —
|
||||
/// never degrades … the paste produces **nothing**, and a one-shot failure banner names it from
|
||||
/// the manifest's metadata").
|
||||
///
|
||||
/// **Its own case rather than a fold into `.copy`**, on `.rename`'s and `.duplicateBoard`'s
|
||||
/// reasoning: the user pressed *Paste*, and a banner telling them the app "couldn't copy 'Fix
|
||||
/// login'" would name a gesture they never made. It is also the one operation in this vocabulary
|
||||
/// the Writer itself never performs — a paste's *arrivals* are `.copy` and `.move` — because the
|
||||
/// refusal happens at the clipboard's preflight, before any arrival is materialized; the
|
||||
/// vocabulary grows with the surfaces, and the surface here is the refusal.
|
||||
///
|
||||
/// `title` is the offending entry's, read off the manifest's own metadata (which is exactly what
|
||||
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
|
||||
/// an untitled item.
|
||||
case paste(title: String?)
|
||||
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
||||
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
|
||||
///
|
||||
@@ -2733,6 +2833,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
case .copy: .copy(title: title)
|
||||
// Identity, like `.repairDuplicateID` and for its reason: a refused paste never opens an
|
||||
// `index.md`, so there is no `readDocument` to enrich from — its title arrives already filled
|
||||
// in from the manifest entry the refusal names.
|
||||
case .paste: self
|
||||
case .delete: .delete(title: title)
|
||||
case .purge: .purge(title: title)
|
||||
case .migrateTombstone: .migrateTombstone(title: title)
|
||||
@@ -2747,6 +2851,46 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whether this rewrite only restates the item's position among its container's members** —
|
||||
/// the reorders-don't-stamp predicate (01-storage-format.md § Frontmatter ▸ `modified`'s scope,
|
||||
/// ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30).
|
||||
///
|
||||
/// One question decides it: **does the rewrite change the item's container?** If it does — a
|
||||
/// cross-lane move, a cross-board arrival, a move into or out of `.trash/` — the item's story
|
||||
/// changed (which lane a card lives in is state) and the write stamps `modified` and clears
|
||||
/// `modified-by` like any content write. If it does not — a card reordered among its lane's
|
||||
/// siblings, a lane reordered on the board, a renumber's whole-lane rescale — only `order` is
|
||||
/// rewritten: no stamp, no clear. Where an item *stands in line* is presentation, and `order` is
|
||||
/// logically the container's property that the format happens to store inside the member's file.
|
||||
///
|
||||
/// **The vocabulary already draws the line**, which is why this is a property here rather than a
|
||||
/// flag threaded through `updateIndex`'s call sites:
|
||||
///
|
||||
/// - `.reorder` is the same-container case by construction — `moveItem` decides it from the source
|
||||
/// parent and the destination parent before it touches disk (`isSameLocation`), and the two
|
||||
/// inverse paths that rewrite a rank directly (`BoardStore.setOrder`, the within-lane sort) are
|
||||
/// same-container for the same reason: a lane's parent is the board root, and a card the sort
|
||||
/// permutes never leaves its lane.
|
||||
/// - `.renumberChildren` is the midpoint-exhaustion rescale — "order-only rewrites, so no
|
||||
/// `modified` stamp and no `modified-by` clear" (01 § Ordering, verbatim).
|
||||
/// - **Everything else stamps.** `.move` covers every container change including the trash's, and
|
||||
/// there is deliberately **no trash case anywhere**: the trash move stamps because every
|
||||
/// container change stamps, so a branch for it would be a second rule saying the same thing.
|
||||
///
|
||||
/// Exhaustive with no `default`, like every other switch over this enum: a new operation has to
|
||||
/// answer "does this rewrite content?" before it compiles.
|
||||
public var rewritesOrderOnly: Bool {
|
||||
switch self {
|
||||
case .reorder, .renumberChildren:
|
||||
true
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A short imperative phrase — `"move 'Fix login'"`, `"create card"`, `"import attachment
|
||||
/// 'photo.png'"` — for logs and diagnostics **only**: `BoardWriteError.description` (test
|
||||
/// failures, `po error`, console output), never the banner's text. The banner owns every
|
||||
@@ -2761,6 +2905,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .move(title): Self.phrase("move", title)
|
||||
case let .reorder(title): Self.phrase("reorder", title)
|
||||
case let .copy(title): Self.phrase("copy", title)
|
||||
case let .paste(title): Self.phrase("paste", title)
|
||||
case let .delete(title): Self.phrase("delete", title)
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
@@ -2844,6 +2989,18 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
/// the *proposal* is not, so nothing was attempted and nothing changed.
|
||||
case invalidSource(BoardLoadError)
|
||||
|
||||
/// **The bytes a paste was to reproduce are not there** — the staged clipboard snapshot is
|
||||
/// missing or unreadable, so the paste produces nothing rather than a hollowed item
|
||||
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29 — Finder's invariant, and 01's
|
||||
/// leniency doctrine: "proceed partially, lose a little" is never a verdict).
|
||||
///
|
||||
/// **Payload-free on purpose.** There is exactly one thing to say about it, the banner owns
|
||||
/// the words (`BannerCenter.causePhrase`), and the offending item is already named by the
|
||||
/// operation's own title — so a free-form message here could only be a second, worse copy of
|
||||
/// a sentence that lives one layer up. That also keeps it distinct from `.unreadable`, whose
|
||||
/// message is a developer's diagnostic about a file the app *did* open.
|
||||
case clipboardContentGone
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case let .unreadable(message):
|
||||
@@ -2856,6 +3013,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
|
||||
message
|
||||
case let .invalidSource(error):
|
||||
"the source text wouldn't load: \(error.description)"
|
||||
case .clipboardContentGone:
|
||||
"the copied content is no longer staged"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +573,22 @@ public enum FrontmatterKeys {
|
||||
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
||||
public static let kind = "kind"
|
||||
|
||||
/// The reserved tracker keys — `remote` (board, card) and `remote-state` (lane) — of the enhanced
|
||||
/// schema's future connectors (01-storage-format.md § Enhanced schema).
|
||||
///
|
||||
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
|
||||
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
|
||||
/// the UI)", and `schemaOwned` is exactly the set `unknownFields` subtracts. Nothing about their
|
||||
/// ordinary posture changes.
|
||||
///
|
||||
/// They are named at all because of the one act that does touch them: **an item-level copy severs
|
||||
/// tracker identity** (§ Fractal layout ▸ Rules, ruled 2026-07-29) — "two local objects must never
|
||||
/// both claim to be the same remote object" — so every folder a paste, an ⌥-drag duplicate, a
|
||||
/// cross-board copy or a template instantiation materializes drops both keys, at every level.
|
||||
/// Whole-board forks (Duplicate, Save as Template) carry them verbatim, as always.
|
||||
public static let remote = "remote"
|
||||
public static let remoteState = "remote-state"
|
||||
|
||||
public static let schemaOwned: Set<String> = [
|
||||
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
||||
iconColor, kind,
|
||||
|
||||
@@ -39,7 +39,69 @@ public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One **lenient** field that had no sensible reading and fell back to its default — the coerce
|
||||
/// tier's observability record (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
||||
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
|
||||
/// integrity service's Defect stream").
|
||||
///
|
||||
/// **Two of the three facts, because this layer only has two.** `FrontmatterFields` is a pure reading
|
||||
/// of one document's bytes and has no idea which file it came from, so the *path* is attached by the
|
||||
/// loader, which does (`CoercedFrontmatter`). Threading a path down here to satisfy the record's shape
|
||||
/// would put filesystem context into the one layer that is deliberately free of it.
|
||||
public struct CoercedField: Sendable, Equatable {
|
||||
/// The frontmatter key, as the schema spells it.
|
||||
public let key: String
|
||||
/// The value exactly as written — the raw source span, which is the only form worth recording:
|
||||
/// this exists so that a shape observed in the wild can later be promoted to a heuristic heal, and
|
||||
/// a normalized rendering of a value nobody could read would defeat that.
|
||||
public let raw: String
|
||||
|
||||
public init(key: String, raw: String) {
|
||||
self.key = key
|
||||
self.raw = raw
|
||||
}
|
||||
}
|
||||
|
||||
extension FrontmatterDocument {
|
||||
|
||||
// MARK: - The coerce tier's own report
|
||||
|
||||
/// Every **lenient** field in this document that had no sensible reading, in schema order — the
|
||||
/// coerce tier's whole observability contribution (01-storage-format.md § Frontmatter, ruled
|
||||
/// 2026-07-29: "the family posture: every silent recovery leaves a trace").
|
||||
///
|
||||
/// **Read-side only, and nothing branches on it.** The values still render as their defaults —
|
||||
/// untitled placeholder, width 1, no color, no icon, no timestamp — the bytes on disk are still
|
||||
/// preserved verbatim, and no banner is raised. The list exists so the fallback is *visible*: it is
|
||||
/// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or
|
||||
/// a notice".
|
||||
///
|
||||
/// **The strict fields are absent, and so is `deleted`.** `schema` and `order` are the *refuse*
|
||||
/// tier — a malformed one fails the load loudly (`IntegrityRules.validatedSchema`/`validatedOrder`),
|
||||
/// so there is no silent recovery to leave a trace of. `deleted` is the odd one out on purpose: its
|
||||
/// rule is *presence, not validity* (a malformed `deleted` still deletes — `Lane`/`Card.isDeleted`),
|
||||
/// so nothing falls back to a default and the migration reports it under its own defect anyway.
|
||||
///
|
||||
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
|
||||
/// and costs one pass over the lenient fields.
|
||||
public var coercedFields: [CoercedField] {
|
||||
var found: [CoercedField] = []
|
||||
func record(_ key: String, _ field: FieldValue<some Sendable & Equatable>) {
|
||||
guard let raw = field.rawText else { return }
|
||||
found.append(CoercedField(key: key, raw: raw))
|
||||
}
|
||||
record(FrontmatterKeys.title, title)
|
||||
record(FrontmatterKeys.width, width)
|
||||
record(FrontmatterKeys.created, created)
|
||||
record(FrontmatterKeys.modified, modified)
|
||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||
record(FrontmatterKeys.background, background)
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
record(FrontmatterKeys.kind, kind)
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - Strict (structure — the loader fails fast on `.malformed`)
|
||||
|
||||
public var schema: FieldValue<Int> {
|
||||
|
||||
@@ -182,6 +182,30 @@ public enum IntegrityRules: Sendable {
|
||||
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
|
||||
]
|
||||
|
||||
/// **The card-level claimed names** — the same table one level down (01-storage-format.md § Fractal
|
||||
/// layout ▸ Rules, extended 2026-07-29: "**The rule is level-uniform**: a card's reserved child
|
||||
/// names are claimed the same way — a regular file or symlink squatting `attachments` (a directory
|
||||
/// name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops,
|
||||
/// and the sidebar listing never fail one gesture at a time against a squatted name").
|
||||
///
|
||||
/// **`attachments` displaces; `comments` does not**, and the split is the *timing principle*
|
||||
/// rather than a hedge — 01 calls the reserved-but-unconsumed `comments` "the timing principle's own
|
||||
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no
|
||||
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the
|
||||
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file
|
||||
/// wears the name, every import into that card, every Finder drop on it, and the card window's
|
||||
/// listing are broken — which is exactly the "proactive when the defect is load-bearing now"
|
||||
/// condition (§ Validation and healing).
|
||||
///
|
||||
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
|
||||
/// squatters — it is the card's content, and a directory named `index.md` makes the folder an
|
||||
/// index-less stray the loader already skips with a warning (§ Fractal layout ▸ Rules). Displacing it
|
||||
/// would mean the app deciding a folder's content is a squatter.
|
||||
public static let claimedCardChildNames: [ClaimedName] = [
|
||||
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
|
||||
ClaimedName(name: "comments", expected: .directory, displacesSquatters: false),
|
||||
]
|
||||
|
||||
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
|
||||
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
|
||||
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })
|
||||
@@ -222,6 +246,38 @@ public enum IntegrityRules: Sendable {
|
||||
return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
|
||||
}
|
||||
|
||||
/// The claimed-name defects inside one **card** folder — the level-uniform half of the same ruling
|
||||
/// (`claimedCardChildNames`, extended 2026-07-29).
|
||||
///
|
||||
/// Only names whose `displacesSquatters` is `true` can produce one, which today means `attachments`
|
||||
/// and only `attachments`: a `comments` held by the wrong kind of node is a tolerated stray until
|
||||
/// the feature consumes the name.
|
||||
///
|
||||
/// **A plural answer, unlike the board root's**, because the reason the root's is singular does not
|
||||
/// apply here: there, the second displacing name (`CLAUDE.md`) is the agent guide's own to heal, so
|
||||
/// answering it twice would be two mechanisms racing one node. Nothing else owns a card's children,
|
||||
/// so this returns every offender it finds and the table stays the only thing to edit when
|
||||
/// `comments` graduates.
|
||||
///
|
||||
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
|
||||
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
|
||||
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
|
||||
claimedCardChildNames.compactMap { claimed in
|
||||
guard claimed.displacesSquatters,
|
||||
let found = node(at: cardFolder.appendingPathComponent(claimed.name)),
|
||||
found != claimed.expected
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return ClaimedNameSquatter(
|
||||
name: claimed.name,
|
||||
found: found,
|
||||
expected: claimed.expected,
|
||||
location: .card(path: path)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Object kinds
|
||||
|
||||
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
|
||||
@@ -454,6 +510,20 @@ public enum IntegrityRules: Sendable {
|
||||
/// and reminted by the scheduled heal (re-ruled 2026-07-29 — the silent remint).
|
||||
case duplicateIdentity(DuplicateIdentity)
|
||||
|
||||
/// **A coerce-tier fallback**: one file's lenient fields that had no sensible reading and
|
||||
/// rendered as their defaults (ruled 2026-07-29 — "A no-sensible-reading fallback logs: field,
|
||||
/// path, and raw text, carried as coerce-tier entries in the integrity service's Defect
|
||||
/// stream").
|
||||
///
|
||||
/// **The one case in this enum that is not work**, which is the ruling rather than a
|
||||
/// contradiction: the design puts coerce-tier fallbacks in *this* stream on purpose, because
|
||||
/// this is where "an observed-in-the-wild shape can later be promoted to a heuristic heal or a
|
||||
/// notice" — and the promotion would happen right here, by giving the case a heal class. Until
|
||||
/// then it has none (`healClass` answers `nil`), raises no banner, and changes no behavior: the
|
||||
/// value already rendered as its default and the bytes on disk are untouched. It is
|
||||
/// observability, carried in the vocabulary that would act on it if the app ever decided to.
|
||||
case coercedFrontmatter(CoercedFrontmatter)
|
||||
|
||||
/// The scheduled-heal classes, which are also the engine's memo keys and its
|
||||
/// banner-posture rows (`HealScheduler`).
|
||||
///
|
||||
@@ -470,12 +540,18 @@ public enum IntegrityRules: Sendable {
|
||||
case staleAgentGuide
|
||||
}
|
||||
|
||||
public var healClass: Class {
|
||||
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the
|
||||
/// coerce tier (`coercedFrontmatter`), which is carried for observability and acted on by
|
||||
/// nothing. Optional rather than a synthetic class, because a class *is* a memo key and a
|
||||
/// banner-posture row in the engine (`HealScheduler`): inventing one for work that does not
|
||||
/// exist would arm a memo against a repair nobody wrote.
|
||||
public var healClass: Class? {
|
||||
switch self {
|
||||
case .looseCardFiles: .looseCardFiles
|
||||
case .legacyTombstone: .legacyTombstone
|
||||
case .claimedNameSquatted: .claimedNameSquatted
|
||||
case .duplicateIdentity: .duplicateIdentity
|
||||
case .coercedFrontmatter: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,13 +571,21 @@ public enum IntegrityRules: Sendable {
|
||||
case let .claimedNameSquatted(work):
|
||||
// The node *kind* is part of the picture: a squatter replaced by a different kind
|
||||
// of squatter is a new defect, and a heal that failed on one has no claim to have
|
||||
// failed on the other.
|
||||
["claimed:\(work.name):\(work.found.rawValue)"]
|
||||
// failed on the other. The location leads, so two cards squatting `attachments` are two
|
||||
// pieces of work — and a board-root squatter signs exactly as it always did.
|
||||
["claimed:\(work.location.signatureComponent)\(work.name):\(work.found.rawValue)"]
|
||||
case let .duplicateIdentity(work):
|
||||
// The *identity* is part of the picture beside the path: the same folder losing a
|
||||
// different collision (its winner reminted, a third copy landing) is new work, and a
|
||||
// heal that failed on one has no claim to have failed on the other.
|
||||
["duplicate:\(work.path):\(work.identity)"]
|
||||
case let .coercedFrontmatter(work):
|
||||
// One signature per field, `looseCardFiles`' shape: the unit of the observation is a
|
||||
// field, and a file whose `width` healed while its `icon` did not is a changed picture.
|
||||
// Nothing memoizes these today — there is no heal to guard — but a signature is what a
|
||||
// defect *is* in this vocabulary, and omitting it would make this case the one members
|
||||
// of the stream cannot be compared by.
|
||||
work.fields.map { "coerce:\(work.path):\($0.key)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -805,18 +889,59 @@ public struct LegacyTombstone: Sendable, Equatable {
|
||||
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
|
||||
/// ladder (`.trash` → `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
|
||||
/// notice naming old and new — the invariant that survives is displacement-never-destruction.
|
||||
/// **The rule is level-uniform** (extended 2026-07-29): a card's `attachments` is claimed exactly as the
|
||||
/// board root's `.trash` is, and displaces by the same ladder — which is why `location` exists rather
|
||||
/// than a second defect type. The heals compose: the displaced file, now an ordinary loose file, rides
|
||||
/// the next loose-file relocation into the real `attachments/`.
|
||||
public struct ClaimedNameSquatter: Sendable, Equatable {
|
||||
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`).
|
||||
|
||||
/// **Which claimed name this is** — the board root's, or one card's reserved child.
|
||||
///
|
||||
/// A path rather than a URL, relative to the board root, so the heal joins it onto the store's
|
||||
/// *current* root and a board renamed mid-session heals at its new location (`LooseCardFiles`' and
|
||||
/// `IdentityOccurrence.path`'s convention).
|
||||
public enum Location: Sendable, Equatable {
|
||||
case boardRoot
|
||||
case card(path: String)
|
||||
|
||||
/// The folder the claimed name lives in, under `root`.
|
||||
public func folder(under root: URL) -> URL {
|
||||
switch self {
|
||||
case .boardRoot: root
|
||||
case let .card(path): root.appendingPathComponent(path, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The location as a signature component — `""` for the board root, so the existing root-level
|
||||
/// signature spelling is unchanged and only a card-level defect adds a path segment.
|
||||
var signatureComponent: String {
|
||||
switch self {
|
||||
case .boardRoot: ""
|
||||
case let .card(path): path + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`, `attachments`).
|
||||
public let name: String
|
||||
/// What is actually sitting there — never followed if it is a symlink.
|
||||
public let found: IntegrityRules.NodeKind
|
||||
/// What the app needs the name to be.
|
||||
public let expected: IntegrityRules.NodeKind
|
||||
/// Where the name lives. Defaults to the board root, which is where every squatter was before the
|
||||
/// rule went level-uniform — so the root-level call sites and their tests read exactly as they did.
|
||||
public let location: Location
|
||||
|
||||
public init(name: String, found: IntegrityRules.NodeKind, expected: IntegrityRules.NodeKind) {
|
||||
public init(
|
||||
name: String,
|
||||
found: IntegrityRules.NodeKind,
|
||||
expected: IntegrityRules.NodeKind,
|
||||
location: Location = .boardRoot
|
||||
) {
|
||||
self.name = name
|
||||
self.found = found
|
||||
self.expected = expected
|
||||
self.location = location
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +980,33 @@ public struct DuplicateIdentity: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's **coerce-tier fallbacks**: the lenient fields whose value had no sensible reading, so the
|
||||
/// field rendered as its default (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
||||
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
|
||||
/// integrity service's Defect stream — the one place where an observed-in-the-wild shape can later be
|
||||
/// promoted to a heuristic heal or a notice; no banner, no behavior change").
|
||||
///
|
||||
/// **Per file, not per field** — `LooseCardFiles`' shape and for its reason: the walk meets a document
|
||||
/// once and reads all of its fields there, so one record per `index.md` is what the loader naturally
|
||||
/// has, and a caller that wants per-field granularity has `fields` (and the per-field `signatures`).
|
||||
///
|
||||
/// The path is root-relative, as every load-side path in this app is (`BoardLoadError.path`,
|
||||
/// `IdentityOccurrence.path`) — it names the `index.md`, because that is the file whose bytes were
|
||||
/// read and the thing a developer would open.
|
||||
public struct CoercedFrontmatter: Sendable, Equatable {
|
||||
/// The `index.md`'s path relative to the board root — `"index.md"`, `"<lane>/index.md"`,
|
||||
/// `"<lane>/<card>/index.md"`, `".trash/<card>/index.md"`.
|
||||
public let path: String
|
||||
/// The fields that fell back, in schema order. Never empty — a document that read cleanly
|
||||
/// contributes no defect at all.
|
||||
public let fields: [CoercedField]
|
||||
|
||||
public init(path: String, fields: [CoercedField]) {
|
||||
self.path = path
|
||||
self.fields = fields
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity — one
|
||||
/// item typed two ways, not two items (01-storage-format.md § Fractal layout ▸ Rules: "the canonical
|
||||
/// all-lowercase spelling wins where present, else the lexicographically first spelling; the loser
|
||||
|
||||
Reference in New Issue
Block a user