Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+182 -55
View File
@@ -116,6 +116,13 @@ public enum IntegrityRules: Sendable {
/// The file every level's content lives in.
public static let indexFileName = "index.md"
/// **The board's noise definition** (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-31; 06-history-undo.md Repository hygiene) a board-root **file** name, claimed
/// because the app seeds it on every board and then reads it on every walk: it is the one list of
/// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what
/// keeps that noise out of history. Seeded once, never edited afterwards.
public static let gitignoreFileName = ".gitignore"
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) a plain reserved
/// child, never a level and never identity; the identities are the UUID folders inside it.
public static let commentsFolderName = "comments"
@@ -180,11 +187,24 @@ public enum IntegrityRules: Sendable {
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
/// 2026-07-29 "Lanework owns the board"), or left exactly where it is.
///
/// `false` for the two names that are *destinations* or not the app's to police:
/// `false` for the two names that are *destinations* or that the app can do without:
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
/// destination by a second displacement would cascade renames (the settled skip stands
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
/// (06-history-undo.md Repository hygiene), and nothing in the app reads it.
/// (06-history-undo.md Repository hygiene).
///
/// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason
/// changed underneath it. It used to be "nothing in the app reads it", which stopped being
/// true the day the file became the loose-file heal's noise gate (01-storage-format.md
/// § Fractal layout Rules) by the level-uniform timing principle (a name "joins the
/// scheduled class the day the name becomes load-bearing", which is how `comments` graduated)
/// a folder squatting it would now be a candidate. It stays undisplaced because **the app
/// degrades to correctness without the file**: an unreadable noise definition means no
/// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an
/// empty file the ruling's own escape hatch asks for. Nothing breaks while the name is
/// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing
/// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here,
/// and inventing one would move a node of the user's to buy a courtesy file.
public let displacesSquatters: Bool
}
@@ -199,7 +219,7 @@ public enum IntegrityRules: Sendable {
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
]
/// **The card-level claimed names** the same table one level down (01-storage-format.md § Fractal
@@ -451,9 +471,15 @@ public enum IntegrityRules: Sendable {
// MARK: - Per-field validation (the rulebook)
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
/// § Malformed input). Required at every level.
public static func validatedSchema(
/// `schema` **at the board root**, validated: present, well-formed, not newer than this app
/// (01-storage-format.md § Malformed input).
///
/// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's
/// identity claim the this-really-is-a-board gate so a root without one is the loud
/// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is
/// what fixes it. Below the root the key is optional and defaults instead
/// (`resolvedSchema(in:path:supportedSchema:)`).
public static func validatedRootSchema(
in document: FrontmatterDocument,
path: String,
supportedSchema: Int
@@ -464,51 +490,111 @@ public enum IntegrityRules: Sendable {
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
return value
}
}
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
/// board itself which is the whole of the per-kind difference in the tables today.
public static func validatedOrder(
in document: FrontmatterDocument,
path: String
) throws(BoardLoadError) -> Double {
switch document.order {
case .missing:
throw BoardLoadError(path: path, reason: .missingOrder)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
case let .valid(value):
return value
}
}
/// Whether an object of `kind` must carry `order` the per-kind field table, as a rule rather
/// than as two hand-written call sites in the loader's walk.
/// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1**
/// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 "below the root a missing `schema`
/// reads as 1 coerce-tier, logged, bytes preserved because the walk validating the file
/// against schema 1 is exactly what makes the reading reliable").
///
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
/// "**No `title`, no `order`**" "Ordering is chronology, not ranks", because a conversation's
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
/// ranks would interleave arbitrarily).
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind {
case .board, .comment: false
case .lane, .card: true
/// Two things stay fail-fast here, and both are the same rule they always were:
///
/// - **A schema newer than the app**, at any level: there is no honest reading of a file written
/// by a Lanework that knows keys this one does not, and defaulting would be guessing at content
/// rather than recovering it.
/// - **A malformed `schema`** `schema: one`, `schema: [1]`. The ruling made the *absent* key
/// optional, not the unreadable one: absence is what a hand-filed item legitimately looks like,
/// while a value that is there and unreadable is a claim the app cannot check, and reading it
/// as 1 would be inventing agreement.
///
/// - Returns: the reading, plus the coerce-tier record when one was made the field, and the
/// text as written (`nil` when the key was present and valid, which is every file this app
/// ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`).
public static func resolvedSchema(
in document: FrontmatterDocument,
path: String,
supportedSchema: Int
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
switch document.schema {
case .missing:
return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil))
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
return (value, nil)
}
}
/// The reading a `schema`-less file below the root gets the schema this document *is* being
/// validated against, which is the argument for the default rather than a coincidence.
public static let defaultSchema = 1
private static func checkNotNewerThanApp(
_ value: Int,
path: String,
supportedSchema: Int
) throws(BoardLoadError) {
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
}
/// `order` **below the board root**, read: `nil` where the file carries no rank this app can
/// use which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`).
///
/// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31
/// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one
/// (`order:` bare, `order: null` the null-as-missing rule), a non-numeric value, and a
/// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and
/// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier
/// bytes preserved verbatim, the reading logged, the rank materializing on the file's first
/// touch (`healOnTouch`).
///
/// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name.
///
/// **The board root never asks.** A board carries no rank it is not among siblings so the
/// key is meaningless there and this is simply not called on it, exactly as it never was.
public static func resolvedOrder(
in document: FrontmatterDocument
) -> (order: Double?, coerced: CoercedField?) {
switch document.order {
case .valid(let value):
return (value, nil)
case .malformed(let raw):
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw))
case .missing:
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil))
}
}
/// One coerce-tier record: the field and **the value exactly as written**, which for an absent
/// key is the empty string there is no text to record, and inventing a rendering of one would
/// defeat the record's whole purpose (a shape observed in the wild, promotable later to a
/// heuristic heal). An explicitly-null key answers with its own (empty) source span, so
/// "started the key and never gave it a value" is preserved as exactly that.
private static func coercedRecord(
for key: String,
in document: FrontmatterDocument,
raw: String?
) -> CoercedField {
CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "")
}
/// **Per-kind index validation** the loader's own checks, in its own order, over bytes that
/// need not be on disk yet (02-architecture.md Components: "the card validator generalized per
/// kind board, lane, card, the enhanced schema's comment when it lands").
///
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
/// point of the outlet the card validator serves, and the body is free text.
/// functions: decode + parse, then `schema` at the root's strictness for a board, at the
/// below-the-root reading for everything else. Nothing further is checked, because nothing else
/// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless
/// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its
/// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the
/// card validator serves, and the body is free text.
///
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
/// edits, and the raw-source Apply this serves replaces the whole file a flow-mapping
@@ -520,9 +606,11 @@ public enum IntegrityRules: Sendable {
supportedSchema: Int
) throws(BoardLoadError) -> FrontmatterDocument {
let document = try BoardLoader.parseDocument(data, path: path)
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
if requiresOrder(kind) {
_ = try validatedOrder(in: document, path: path)
switch kind {
case .board:
_ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
case .lane, .card, .comment:
_ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
}
return document
}
@@ -551,6 +639,23 @@ public enum IntegrityRules: Sendable {
/// add a key that is redundant with position everywhere else.
case kindBackfilled(ObjectKind)
/// `order` was missing or unusable and has been stamped with the rank the board was
/// **already rendering it at** the append-at-end reading, materialized
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch:
/// the first Writer rewrite of the file stamps a real rank").
///
/// Nothing moves on screen when it lands, and that is the design rather than a happy
/// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the
/// heal writes down what the reading already said. It is on-touch for the reason the whole
/// class is the defect is latent, the file is being rewritten anyway, and a sweep that
/// rewrote a board to add ranks nobody asked for would be churn.
///
/// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and
/// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here:
/// a rank must be a number for the midpoint math to mean anything), so the stamp is the same
/// heal in both cases and the unreadable text does not survive the rewrite.
case rankStamped(Double)
/// A key written twice collapsed to its winning (last) occurrence the span editor's
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
@@ -569,28 +674,43 @@ public enum IntegrityRules: Sendable {
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
/// rewriting").
///
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
/// change".
/// Two of the four members are applied *here* the `kind` backfill and the rank stamp; the
/// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are
/// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same
/// class, named, no behavior change".
///
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
/// stripped the value names the kind and consumers trust it outright. An explicit `kind:`
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
/// and so backfills.
/// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and
/// never stripped the value names the kind and consumers trust it outright. An explicit
/// `kind:` with nothing after it reads as missing, like every other null (the null-as-missing
/// rule), and so backfills.
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
/// trusts what it finds.
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
/// - **`order`: missing *or unusable*.** The reading is stated over usability
/// (`resolvedOrder(in:)`), so the stamp is too.
/// - **`rank == nil` stamps nothing** the board root (which has no siblings and no rank), a
/// comment (which never gains one "Ordering is chronology, not ranks"), and any folder whose
/// position has no answer. The same posture as `kind`'s: a rank invented for something that is
/// not a lane or a card would be a fact nobody asked for.
/// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing
/// (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that
/// actually needs the heal which, on a board this app wrote, is never.
@discardableResult
public static func healOnTouch(
_ document: inout FrontmatterDocument,
kind: @autoclosure () -> ObjectKind?
kind: @autoclosure () -> ObjectKind?,
rank: @autoclosure () -> Double? = nil
) -> [OnTouchHeal] {
guard document.kind.isMissing, let kind = kind() else { return [] }
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
return [.kindBackfilled(kind)]
var applied: [OnTouchHeal] = []
if document.kind.isMissing, let kind = kind() {
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
applied.append(.kindBackfilled(kind))
}
if document.order.value == nil, let rank = rank() {
document.set(FrontmatterKeys.order, to: .double(rank))
applied.append(.rankStamped(rank))
}
return applied
}
// MARK: - The typed defect stream
@@ -640,6 +760,12 @@ public enum IntegrityRules: Sendable {
/// It is a class here because the engine treats it exactly like the others same gates,
/// same memo, same clear-on-success.
///
/// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise
/// definition is one board-root file's presence, read at the moment of healing
/// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk "a board
/// missing the file gains it by scheduled heal at open (the guide-refresh cadence)"
/// (06-history-undo.md Repository hygiene, re-ruled 2026-07-31).
///
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
/// moment the card window opens (01-storage-format.md § Enhanced schema "purged when the
@@ -652,6 +778,7 @@ public enum IntegrityRules: Sendable {
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
case missingGitignore
case commentTrashResidue
}