Formalize the integrity taxonomy and service — the validator design pass

01 gains Validation and healing (five verdicts: refuse / refuse-writes /
tolerate / coerce / heal; heal axes consent x timing incl. the inline mode;
write discipline decided by the existing stamp rule; disk re-verify, memo
discipline, defer-under-lock). kind re-ruled: any object may carry it, the
value names the kind, trusted where consulted, no stripping; backfill is the
service's on-touch heal, on-touch only. 02 gains IntegrityRules +
HealScheduler components — rules and policy consolidate, enforcement stays
with loader/writer.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-29 13:59:46 -04:00
parent aad8857ea0
commit 1b02f6e063
2 changed files with 21 additions and 3 deletions
+18 -2
View File
@@ -73,7 +73,7 @@ Body: board description (free Markdown).
|---|---|---|---|
| `order` | number | yes | Rank among lanes, ascending = left-to-right |
| `width` | integer | no | Width multiplier ≥ 1 (default 1); no cap |
| `kind` | string | no | Always `lane` (ruled 2026-07-29) — written at lane creation, healed on touch when absent, never stripped. Redundant with depth on the live board; load-bearing inside `.trash/`, where it discriminates a trashed lane from a card (Deletion below). Cards never carry it. |
| `kind` | string | no | Always `lane` (ruled 2026-07-29) — written at lane creation, backfilled on touch when absent (the integrity service's on-touch heal — Validation and healing below), never stripped. Redundant with depth on the live board; load-bearing inside `.trash/` (Deletion below). **Any object may carry `kind`, and the value — never the key's mere presence — names the object's kind** (re-ruled 2026-07-29): consumers that consult the key trust its value outright, with no stripping and no corroboration machinery; the live board never consults it (level is position), the trash consults it first. The app writes it only on lanes — accept liberally, emit conservatively. |
Body: lane description / WIP policy / notes.
@@ -115,7 +115,7 @@ Carried over unchanged — gapped fractional ranks:
The tombstone model is **retired** — no `deleted:` key, no in-place flags, no ancestor-walked hiding. Deletion is a **move**:
- **Deleting a card or a lane moves its folder into `<board-root>/.trash/`** (lanes joined 2026-07-29 — retiring the design's sole destructive delete) — a reserved, app-claimed name at board root (joining `CLAUDE.md` and the seeded `.gitignore` on the claimed list; agents and hand-editors should treat it as the app's). `.trash/` holds card and lane folders interleaved directly, no `index.md` of its own. Every arrival lands at the **top** regardless of kind: the move mints an `order` rank above the current topmost, so the trash sorts newest-first by ordinary ranks — no timestamp sort, no tie-break rules. The move **stamps `modified`** (deletion is an edit to the item's story — the deliberate exception to moves-don't-stamp), which is what a future age-based auto-purge will read (deliberate follow-up, not in the initial release). A trashed lane keeps its subtree untouched — its cards ride along and come back with it.
- **`kind:` discriminates inside `.trash/`** (ruled 2026-07-29): depth defines meaning *on the live board*, but the trash is flat, and an empty lane folder is shape-identical to a card folder (`<uuid>/index.md` both). Lane `index.md` frontmatter therefore carries **`kind: lane` at all times** — written at lane creation, healed on touch when absent (minimum: the trash move stamps it; the fuller healing posture is the validation-service design, tracked on the Redesign board), never stripped (restore stays a pure move; the key is inert on the live board where depth wins). Cards never carry the key — absent means card. The trash reader's rule: `kind: lane` → lane; no key but UUID-shaped children with their own `index.md` → lane (self-heal the key); neither → card.
- **`kind:` discriminates inside `.trash/`** (ruled 2026-07-29): depth defines meaning *on the live board*, but the trash is flat, and an empty lane folder is shape-identical to a card folder (`<uuid>/index.md` both). Lane `index.md` frontmatter therefore carries **`kind: lane` at all times** — written at lane creation, backfilled on touch when absent (settled 2026-07-29 — the integrity service's on-touch heal, Validation and healing below: any Writer rewrite of that lane's `index.md`, the trash move's rank mint included, stamps the missing key; **on-touch only, never a scheduled backfill sweep**), never stripped (restore stays a pure move; the key is inert on the live board where depth wins). The trash reader's rule **trusts the value** (re-ruled 2026-07-29 — the value-names-the-kind posture, Frontmatter above): `kind: lane` → lane; `kind: card` → card; an unrecognized value or no key falls through to shape — UUID-shaped children with their own `index.md` → lane (the key backfills on the next touch), else card. Trusting the value means an external writer's `kind: lane` on what looks card-shaped is honored, never policed — the app never writes the key on cards, so its presence is a deliberate claim. Honest limit of on-touch-only backfill, accepted: a kind-less trashed lane emptied of its children before any touch becomes indistinguishable from a card.
- **Restore is a move out**: any move of a card folder from `.trash/` into a lane, or of a lane folder to board level — drag, or cut+paste — is an ordinary move; there is no restore-specific machinery and no Put Back. Position (and, for cards, lane) are chosen at restore time.
- A folder that disappears entirely (Finder deletion) is also a delete; the app reflects it — such items are gone, they never enter the trash.
- Physical removal in-app is the trash's **Delete / Delete Immediately / Empty Trash** (03-board-ui.md).
@@ -127,6 +127,22 @@ Loud, specific error (path + what's wrong) for: unparseable YAML, missing requir
Fail-fast is the **initial-load** contract. Once a board is open, a failed live reload does not blank the board: the window keeps the last good snapshot and surfaces the same loud specifics in a non-modal banner — see 02-architecture.md's live-reload resilience.
## Validation and healing — the integrity taxonomy (settled 2026-07-29)
Every rule in this document that refuses, tolerates, recovers, or repairs is an instance of **one five-verdict taxonomy**, owned by one vocabulary — the **integrity service** (`IntegrityRules` + `HealScheduler`, 02-architecture.md ▸ Components). A detectable defect classifies into exactly one verdict, and the verdict fixes everything downstream — surface, write behavior, race posture — so no mechanism ever re-reasons its posture individually:
- **Refuse** — fail-fast (Malformed input above): the defect defeats rendering or ordering. Initial-load refusal; once open, the reload-breakage condition (02-architecture.md).
- **Refuse writes** — readable-but-uneditable shapes (Frontmatter above): the file renders fine, and every app write to it fails loudly per file.
- **Tolerate** — outside the schema's claim: strays, symlinks, case-twins, lane- and board-level `deleted:`. Preserved verbatim, logged, never rendered (or rendered with the key inert).
- **Coerce** — a sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue family): silent, read-side only, bytes preserved — the recovered value is exactly what the writer meant.
- **Heal** — an app-owned invariant is violated *and* a lossless canonical repair exists: the only verdict that writes.
Heals classify on two axes. **Consent**: *app-initiated* when the repair is semantically unambiguous and content-lossless (loose-file relocation, legacy card migration, `kind` backfill, the agent guide); *user-gated* when the fix chooses identity or interpretation (the duplicate-id **Repair** mints identity, so it waits behind its condition banner — Fractal layout above). **Timing**, three modes: *inline* when the triggering gesture cannot proceed without it (the midpoint-exhaustion renumber, the import-boundary remint — each stays inside its gesture's write bracket and commit); *on-touch* when the defect is latent — the fix folds into a write already rewriting that file (`kind` backfill, duplicate-key twin removal, the colon rescue's quote-on-first-write); *scheduled* when the defect degrades behavior while it stands (loose files invisible to the attachment surface, `deleted:` cards rendering by a retired model, a stale agent guide actively misleading agents) — run at the reload tail and at board open, uniformly. The timing principle: **proactive when the defect is load-bearing now, on-touch when it's latent.**
**Write discipline needs no rule of its own** (settled — the existing stamp rule decides every case): a heal that rewrites an `index.md` is an ordinary app write — stamps `modified`, clears `modified-by`; an on-touch heal rides its host write's stamp — while a heal that only renames or relocates folders and files never opens `index.md` and stamps nothing (the relocation, the remint — identity repairs, not edits). Atomicity is per file (temp + rename); interrupted batches are accepted per the renumber precedent — every intermediate state is valid. **Every scheduled heal re-verifies its defect against disk at write time** and no-ops when it is gone: losing the race to a foreign fix — an agent mid-edit, another repair — is success, never an error (the Repair-races-a-vanished-duplicate precedent, generalized). Scheduled heals are memo-guarded against hot loops — armed before the attempt, cleared explicitly on success, re-armed only by a changed defect signature — and **defer, never abandon**, under any read-only lock.
The vocabulary is per-kind and extensible: adding an object kind (the enhanced schema's comment) adds its field table and shape rules in one place, never a parallel mechanism.
## Document packaging
- Boards are created as `.kanban` packages (UTI conforming to `com.apple.package`) so Finder treats a board as one document.