zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Kanban Directory Model

Authoritative spec for the directory-as-board kanban storage format introduced in Epic #2267. A directory is a kanban board iff it contains KANBAN.md: a manifest (board props + per-column ordered card link-lists) plus one markdown file per card. Locks the manifest grammar, card stable-identity/rename contract, and reconciliation rules that every downstream sub-issue implements against.

Warning

This document is the deliverable for sub-issue #2268 (Wave 1 of Epic #2267, Kanban Directory Model). It is the single source of truth for the manifest grammar, the card stable-identity/rename contract, and the card + column reconciliation rules. If a parser, serializer, hook, or provider disagrees with this doc, the doc wins and the code must change to match. Downstream waves (S3 adapter, S4 provider cutover, S6 convert/new/revert) implement directly against the grammar and pure-function signatures locked here.

Scope

This epic restructures the storage backing of the kanban feature from "one markdown file = one board" to "one directory = one board." The in-memory KanbanBoard / KanbanCard shape (packages/kanban-parser/src/types.ts) remains stable for the kanban UI (@takazudo/kanban-board, including the now/calendar layouts). KanbanCard.title is retained as a read-only, parser-derived convenience field; it is not independent card data. The pure adapter translates between the plain markdown contract and that in-memory shape.

The adapter is a set of pure functions in @takazudo/kanban-parser (directory-model.ts — signatures locked in this doc). A thin renderer hook (useKanbanDirectory, S4) does the workspaceFiles.* I/O. Detection, read assembly, and write planning are all pure and exhaustively unit-tested — that is the CI-confidence lever, since CI runs Chromium DOM-only and cannot exercise real workspace I/O.

Pre-release: no migration

The app has not shipped a first release. There is no migration of old single-file kanban boards and no backward-compatibility shim. The single-file kanban format and its in-editor surfaces (editor-pane kanban view-mode, in-editor TODO→Kanban) are removed outright by later waves. Any existing single-file board is assumed re-creatable from scratch. Do not write code that reads, detects, or upgrades the old single-file format.

Boards live in the workspace (epic #4204 D1/D4, S14/#4218)

Boards are workspace-scoped: directoryPath is a workspace-relative POSIX path and every storage operation goes through bridge.workspaceFiles (list / read / write / delete / listAll). bridge.files is local-only — the External File Editor surface (D5) — and must not be reintroduced into any board code path.

Three consequences are worth stating outright, because they change behavior rather than just the call site:

  • There is no mkdir. A workspace directory has no independent existence; it is inferred from the keys under it. Writing KANBAN.md is what brings a board directory into being, so the setup flows have no directory-creation step.

  • An empty listing means "gone". list on a prefix with no keys returns [] rather than rejecting, so an empty listing is the workspace's only way of saying the folder does not exist. That is what the board frame reports as dirMissing (#3215); a listing with other files but no KANBAN.md is notABoard.

  • A missing key reads as null, not an error. read distinguishes "absent" (null) from "refused" (rejects — containment violation, workspace not armed), which removes the old listing-based not-found probes the trash path needed when adapters disagreed on error text.

The former local-folder↔kanban conversion commands were retired in S14: they crossed between a workspace-relative board and a local filesystem surface, which are different storage systems. "New Kanban Board…" (a workspace folder picker) covers the "make this folder a board" case; un-boarding a folder has no workspace-native replacement yet.

Detection

A directory is a kanban board if and only if:

workspaceFiles.list(dir) includes a non-directory entry named "KANBAN.md"

The exact filename is exported as the constant KANBAN_FILENAME = "KANBAN.md". A directory without KANBAN.md is just a directory. Adding KANBAN.md makes the directory a board; deleting it un-boards it. No frontmatter scan, no file-content sniffing, no recursion is involved in detection — presence of the manifest file is the whole rule.

Stored layout

A board directory contains the manifest plus one .md file per card:

my-project/                  ← the board (a directory)
├── KANBAN.md                ← manifest: board props + per-column card link-lists
├── refine-the-spec.md       ← a card (its own file)
├── ship-the-adapter.md      ← a card
└── write-the-tests.md       ← a card

Subdirectories and non-.md files are ignored by the board model (a nested directory is its own potential board target; image assets referenced by card bodies live wherever the body's markdown points). Only top-level .md files other than KANBAN.md are candidate card files.

KANBAN.md — the manifest

KANBAN.md is the board manifest. It is the single source of truth for column membership and card order. It has two parts: YAML frontmatter carrying the board props, and a body of one ## Column heading per column, each followed by a markdown link-list of the cards in that column, in order.

Manifest frontmatter

The frontmatter carries exactly the KanbanBoard fields minus cards — the same field set the current single-file kanban frontmatter uses. The serialized shape mirrors the existing single-file serializer (packages/kanban-parser/src/serializer.ts), so the manifest frontmatter grammar is already specified and tested there; only the body differs.

KeyTypeNotes
typekanban (marker)Always written first. Marks the manifest as a kanban board.
titlestringBoard title.
columnsstring[]Authoritative column set + display order (see Column reconciliation).
collapsedstring[]Column names rendered collapsed.
groupBystring (optional)Grouping field for the board UI.
viewsKanbanView[] (optional)Saved views (name, optional groupBy/filter).
labelDefsLabelDef[] (optional)Label name→color definitions.
columnLabels{id, label}[] (optional)Display-label overlay over the stable column ids (#3332). See "Column display-label overlay" below.
cardWidthnumber (optional)Canonical/core per-board card width in CSS px. Rendered width is derived from this value and cardMode; an absent value remains absent.
cardModenormal | bigger | biggest (optional)Independent per-board card-content/size preference. Absence resolves to Normal without persisting a default.

Example:

---
type: kanban
title: My Project
columns: [Backlog, In Progress, Done]
collapsed: [Done]
labelDefs: [{name: bug, color: #e11d48}, {name: feature, color: #2563eb}]
columnLabels: [{id: Backlog, label: "To Do"}]
cardWidth: 280
cardMode: bigger
---

cardWidth is always the canonical Base width, never the rendered large-card width. List rendering derives the card border-box width using exact ratios: Normal = 1/1, Bigger = 15/11, and Biggest = 20/11, rounded to the nearest CSS pixel. The desktop column/header/cell/overlay border box is 16 px wider than the derived card. Derived widths are not clamped a second time, so a Base width of 480 produces 655 px Bigger and 873 px Biggest cards. Application display scale is applied after this derivation.

The two fields persist independently. A mode-only write must preserve an absent, explicit-default, or non-default cardWidth exactly; it must not materialize a default width. Likewise, a width-only write preserves cardMode. The legacy CardDensity concept is not part of the parser, board, or manifest contract.

cardMode changes List presentation only. Bigger renders the card body through the host Preview renderer up to the first exact standalone root <!-- more --> HTML comment; Biggest renders the complete body. The marker is presentation structure and remains in the card file. Nested, inline, fenced-code, and non-exact marker lookalikes are ordinary content. A parser-confirmed leading ATX H1 is omitted from the rendered body because it supplied the card title; Setext H1 content is retained. Now and Calendar layouts do not consume this large-card presentation contract.

status and order are not board-frontmatter fields and not card-frontmatter fields — they are derived from manifest body position (below).

Manifest body

The body is one ## ColumnName heading per column, each immediately followed by a markdown list of links to the card files in that column. List position = card order within the column. Heading name = each listed card's status.

## Backlog

- [Write the tests](./write-the-tests.md)
- [Refine the spec](./refine-the-spec.md)

## In Progress

- [Ship the adapter](./ship-the-adapter.md)

## Done
  • Each list item is a standard markdown link - [<linkText>](./<cardFile>.md). The link path addresses the card file; the link text is a human-readable label (kept in sync with the card title on rename — see below).

  • The list order is the card order within that column. Reordering a card = reordering one list item in KANBAN.md — a single manifest write, no card file touched.

  • Moving a card to another column = moving its link line under a different ## Column heading — again a single manifest write.

  • Archiving a card = removing its link from the manifest while leaving the card file in place with archived: true. serializeKanbanManifest always filters archived cards, even though they retain an in-memory status for restore behavior.

  • A column heading with no list items below it (like ## Done above) is a valid empty column.

This is why the manifest is the single source of truth: a card's status and order exist only as its placement in the manifest. The card file itself carries no status/order.

Card files

Each card is its own .md file in the board directory: YAML frontmatter for intrinsic metadata, then a markdown body.

Card frontmatter

KeyTypeNotes
idStablestring (12-char base32)Durable stored identity and the notify registry key. Generated on first save via generateStableId().
priorityurgent | high | medium | low (optional)
duestring (optional)Due date.
labelsstring[] (optional)
imageAttachmentsstring[] (optional)Ordered canonical filenames in the app's assets/ storage. Serialized as one inline scalar list before notify; omitted when empty.
notifynotify-spec (optional)Same grammar as today (<ISO local datetime> <recurrence> [interval=N] tz=<zone>). Keyed for the notification registry by idStable.
archivedliteral true (optional)Marks the card as archived. Archived cards remain in board.cards and keep their file, but are omitted from KANBAN.md.
archivedAtISO 8601 local datetime (optional)Timestamp written by archiveCard, including the local UTC offset.
archivedFromcolumn id (optional)Column occupied immediately before archive; used by the restore fallback chain.
(any other key)preserved verbatimUnknown keys are round-tripped untouched (see Unknown-key preservation).

Example card file ship-the-adapter.md:

---
idStable: k7m2p9q4rs3t
priority: high
due: 2026-06-10
labels: [backend, p1]
project_code: ZT-204
---

# Ship the adapter

Implement `planDirectoryWrites` and wire the renderer hook.

![diagram](./assets/adapter-flow.png)

Notes:

  • A card has no independent title field. KanbanCard.title is derived on every read and in-memory body edit: skip leading blank lines, then use the first line only when it is an H1 (# Title). If that first non-blank line is not an H1, later H1s are ordinary body content and the stable filename slug is the title fallback.

  • serializeCardFile always removes a stale title: frontmatter key on write. This is a pre-release breaking format change, not a migration or compatibility read path; the body is authoritative.

  • status and order are NOT stored in the card frontmatter — they are derived from the manifest. A card file moved between columns or reordered does not change. archivedFrom is only a restore hint, not current manifest membership.

  • images is derived (not stored): the card's image list is extracted from the body via the existing extractImages() helper, exactly as today.

  • imageAttachments is stored metadata and is independent from images. Neither field is synthesized from the other. Attach-only actions can therefore add a cover/gallery item without changing the Markdown body, while editor paste intentionally updates both channels in one card write.

  • idStable is written on first save and is durable. It is the key the server-side notification registry uses, so it must survive renames without a delete+create churn — which the stable-identity contract below guarantees.

Unknown-key preservation

Per the frontmatter-schema / timestamp-writer policy (tauri-app/CLAUDE.md → "Frontmatter Schema" + "Timestamp writer policy"): a card file's frontmatter is a user-editable workspace document. Keys the kanban model does not recognize (e.g. created_at, updated_at, project_code, sidebar_position) are preserved verbatim on round-trip. The serializer is given the previous file content (serializeCardFile(card, prevContent)) so it can re-emit unknown keys unchanged rather than dropping them.

The legacy title key is the deliberate exception: it is recognized only so the serializer can remove it. It never participates in title derivation.

This also keeps the timestamp-writer contract intact: created_at / updated_at and any auto: schema field are owned by the renderer's frontmatter layer, not by the kanban serializer. The kanban serializer never injects or rewrites timestamps — it passes them through verbatim from prevContent.

The three archive keys are recognized fields. Unchanged archivedAt and archivedFrom entries are nevertheless left byte-for-byte intact during unrelated card writes, including malformed values and inline comments. Malformed values do not participate in restore behavior (archivedFrom must exactly name a current column), and the next archive-state write (archiveCard or restoreCard) replaces or removes them canonically.

Card image attachment contract

Card images use the app's existing assets/ storage and two deliberately separate card channels:

  • imageAttachments is an ordered list of decoded, bare asset filenames stored in frontmatter, for example imageAttachments: ["diagram 1.png", "hero.avif"].

  • images is the parser-derived list of Markdown image destinations in the card body. It is never serialized as frontmatter.

An attachment entry is valid only when it is the exact filename returned by assets.saveFile or assets.importFile. It cannot contain assets/, ../assets/, a slash or backslash, URL/query/fragment syntax, or . / .. path components. Parsing trims valid inline-list values and collapses canonical duplicates first-occurrence-wins. An absent or empty field becomes [] in memory and is omitted on serialization. Malformed raw imageAttachments frontmatter is ignored by the typed model but preserved through unrelated writes; an intentional attachment change replaces it with the canonical inline list. There is no legacy alias or migration path before the first release.

Canonical filename identity and display order

Frontmatter stores decoded filenames. Markdown uses the existing asset formatter, so spaces and other destination characters are percent-encoded in ../assets/<filename>. Canonical comparison percent-decodes a valid Markdown destination once, strips exactly the ../assets/ prefix, and rejects malformed encoding and traversal. Remote URLs, data URLs, and body images that do not resolve into asset storage can still render as Markdown, but do not enter the attachment cover/gallery.

The cover/gallery sequence is:

  1. imageAttachments in stored order.

  2. Workspace-asset Markdown images in body order.

  3. Canonical filename duplicates removed, preserving their first occurrence.

The first resulting item remains the cover even if its bytes are missing. The UI shows a stable missing-image placeholder instead of promoting a later item, so a transient read failure never silently changes the cover identity.

Formats, size boundary, and gesture transaction

The canonical image formats are PNG, JPG/JPEG, GIF, WebP, SVG, BMP, ICO, and AVIF, case-insensitive. TIFF/TIF are unsupported across renderer, mock, REST/cloud, and Rust paths. Browser files may use a supported extension with an empty MIME type; when MIME is present it must be a compatible supported image MIME. Clipboard blobs require a supported image MIME and derive an extension when no filename exists. Native paths are classified by extension.

The per-image limit is 25 MiB, inclusive. The renderer rejects known oversize or incompatible inputs before card mutation, and the storage boundary checks decoded bytes again. A gesture preserves source order and saves every accepted image before performing one board/card mutation for the successful subset. All failures means no mutation; partial success commits the successes once and reports the named failures. Clipboard filenames use one UTC timestamp per gesture: pasted-image-YYYYMMDD-HHmmss-<1-based-index>.<ext>.

The backend owns sanitization and collision suffixes such as name-1.png; callers persist only returned filenames. Browser and native delivery of the same OS gesture share one renderer gesture identity and are applied exactly once. A failed later card write retains the already-saved filenames for an idempotent write-only retry — it never uploads the bytes again. If storage succeeds but that write fails, the asset bytes are an acknowledged orphan until retry. This feature never deletes or garbage-collects asset bytes.

Input ownership and view boundary

Drop target precedence is editor, detail, card, then list, using the deepest visible, connected, unoccluded target at the pointer. Editor drops/pastes insert one encoded Markdown image per line in one CodeMirror transaction and append the same filenames to attachment metadata. Detail/card drops append metadata without changing body. List drops create one card per successful image in input order, with only # <source basename> as body and the saved filename as its sole attachment.

Clipboard routing chooses a focused Kanban body editor, then the focused open detail, then the most recently activated visible detail, otherwise the create/attach chooser. The chooser revalidates its current column/card destination at confirm time and restores initiating focus on cancel.

List/card file targets and card covers are first-release features of the flat List layout only. Swimlane, Now, and Calendar retain their existing card presentation and do not register list/card file targets. A card opened from Now or Calendar still uses the shared detail/editor drop and paste behavior.

Object-URL cache and read failures

Attachment display uses a workspace-scoped cache keyed by canonical filename. It coalesces in-flight reads, reference-counts consumers, and evicts unused ready URLs at 64 entries or 128 MiB decoded bytes, whichever comes first. In-use entries may temporarily exceed the bound and are reconsidered when released. Eviction and workspace/backend teardown revoke object URLs. Missing/read errors remain cached for 30 seconds; Retry clears that error explicitly, preventing rerenders from repeatedly reading the same missing asset.

Full-height flat-list geometry

Every visible uncollapsed flat-list shell stretches to the padded bottom of the board scrollport. The pane, board, scrollport, shell, and drop body form a bounded min-height: 0 flex chain. Each drop body is flex: 1, vertically scrollable, and contains a flexible unused-space element, so an empty or sparse column accepts a drop at its bottom edge. Long columns scroll internally while horizontal board scroll remains independent. Collapsed columns and Add Column never register list file targets.

Archive contract

Archiving is a reversible state change, not deletion:

  • archiveCard(board, cardId) keeps the card in board.cards, preserves its idStable, filename, body, and current positional fields, then writes archived: true, a local-offset archivedAt, and archivedFrom: <current status>.

  • serializeKanbanManifest omits every card whose archived flag is true. This central serializer rule prevents a flagged card with a retained status from being accidentally re-listed.

  • planDirectoryWrites treats all three archive fields as intrinsic content. An archive therefore produces one card-file write plus the manifest de-listing, with no file move and no cardDeletes; .trash/ remains the permanent-delete path.

  • restoreCard(board, cardId, columnId?, order?) clears all three archive fields and re-lists the card. Its target chain is: valid explicit column, valid archivedFrom, then the first board column. With no columns it returns the board unchanged and emits a runtime warning. Without an explicit finite order it appends after the active cards in the target column.

  • isActiveCard(card) owns the archive predicate; activeCards(board) applies it as the board-level visibility selector for board, now, calendar, swimlane, filter, keyboard, notify, and related consumers. No consumer should duplicate the archived !== true check.

  • Archived card files produce no notification item. Their ordinary non-empty card-write dispatch reconciles items: [], clearing the existing server row; the file is not routed through the deletion/empty-content dispatch.

Card stable-identity contract (the rename trap)

This is the most important locked decision. It resolves the "rename trap": the in-memory KanbanCard.id is a slug derived from the parser-derived title and changes whenever the leading H1 changes (operations.ts updateCard re-derives the title from body and re-slugs id), whereas a card's entry in the workspace must have a stable key so the manifest link path stays valid and the entry is not deleted+recreated on every rename.

The contract:

  • Stored identity is idStable (the 12-char base32 ID), not the slug id and not the filename.

  • The filename is a uniqueSlug chosen once at card creation and is stable for the card's entire life. It never changes, even when the title changes.

  • The adapter threads a sidecar index, fileIndex: Map<idStable, filename>, alongside the board. This map is the stored identity table: given a card's idStable, it yields the file that card lives in. assembleBoard builds it on read; planDirectoryWrites consumes and returns the updated copy on write.

  • planDirectoryWrites also returns the normalized board represented by its manifest/card writes. The persistence consumer adopts that board only after a successful commit, so a newly allocated titleless card-2.md immediately has the same card-2 title/id that the next read will derive.

What happens on a title rename

When a user renames a card from "Ship the adapter" to "Ship the directory adapter":

  1. The leading body H1 becomes # Ship the directory adapter.

  2. The manifest link text is updated to the new title: - [Ship the directory adapter](./ship-the-adapter.md).

  3. The card file is NOT moved or renamed — it stays ship-the-adapter.md, so the manifest link path is still valid.

  4. idStable is unchanged → the notification registry key is unchanged → no delete+create churn.

In other words: on rename, only the body and manifest link text change; the filename and idStable are immutable for the card's life.

Why operations.ts re-derives title and id

operations.ts does not accept title as an update. A rename is a body edit, and updateCard immediately calls the same deriveCardTitle(body, filenameFallback) contract as parseCardFile, then re-slugs the transient in-memory id:

  • The in-memory id is a transient UI/React key, not a storage address.

  • Directory-backed mutation passes fileIndex, so removing a leading H1 uses the actual stable filename stem even after a filename-divergent rename. Other callers may omit the optional index and retain the transient-slug fallback.

  • Storage keys on idStable, which updateCard never touches.

  • The filename comes from fileIndex[idStable], not from card.id.

  • As a final transactional boundary, planDirectoryWrites re-derives title/id for changed-body cards, newly allocated cards, and queued snapshots whose provisional identity differs from the latest committed baseline. The hook commits the returned normalized board only after the commit succeeds.

So the slug churn in updateCard (the in-memory id flipping from ship-the-adapter to ship-the-directory-adapter) has no consequence for storage. The adapter looks the card up by idStable in fileIndex to find its stable filename; the in-memory id is never used to locate a file.

Card reconciliation

Disk and manifest can disagree (a card file added by hand, a manifest link to a deleted file). The adapter reconciles deterministically on read (assembleBoard), producing an in-memory board plus a list of non-blocking FormatWarnings. The healed state is never written back during a read — only the next explicit save persists it.

SituationResolution
Unflagged orphan file — a .md file in the directory that no manifest column links to and that has no archived: true flagTreated as a real card. Appended to the first column, ordered among other active orphans by filename. Surfaced in-memory immediately; written into KANBAN.md only on the next explicit save, never on passive read.
Archived orphan — an unlinked card file with archived: trueLoaded into board.cards as archived without adding it to the first column, synthesizing Backlog, or emitting an orphan warning. A valid archivedFrom supplies its inert in-memory status; otherwise the first existing column (or "" on a zero-column board) is used without healing membership.
Manifest/archive conflict — the manifest links a file carrying archived: trueManifest membership wins. The complete archive state is cleared in memory and a warning is emitted. The raw prior card content forces one card-file write on the next explicit save so the heal is persisted even though the assembled previous and next board objects both look active.
Dangling link — a manifest link to a file that does not existThe link is dropped from the in-memory board and a non-blocking FormatWarning is emitted. The board still loads.
Empty board — directory contains only KANBAN.md, no card filesAn empty board with the declared columns and no cards.

Why never write on passive read

If assembleBoard (or the hook) wrote the healed manifest back to the workspace on read, it would create a self-perpetuating loop:

read dir → assemble (heal orphan) → write KANBAN.md → file watcher fires
        → re-read dir → assemble → write … (loop)

Healing on read also means a board could be silently rewritten just by being opened — surprising and unfriendly to external editors / sync. So the rule is firm: healing is computed on read and surfaced in memory + as warnings, but is only persisted by the next user-initiated save. A read never writes to the workspace.

Column reconciliation (authority rule)

The frontmatter columns: string[] is the authoritative column set and display order. The ## Column headings in the body declare card membership but do not override the column ordering. Reconciliation is self-healing and mirrors orphan-file handling:

SituationResolution
A ## Column heading whose name is not in columnsThe column name is appended to columns (preserving its declared cards) and a non-blocking FormatWarning is emitted. Self-healing — written back only on the next explicit save.
A columns entry with no matching ## Column headingA valid empty column. No warning.

No ordering conflict exists

There is deliberately no ordering ambiguity to resolve, and an S3 implementer must never have to decide unilaterally:

  • columns orders the columns (left-to-right display order).

  • Each column's link-list orders the cards within that column.

These two orderings are orthogonal. The frontmatter never orders cards; the body link-lists never order columns. When a heading is appended to columns for healing, it goes at the end (rightmost), matching the orphan-file "append to first column" spirit of additive, non-destructive healing.

Column display-label overlay (#3332)

Post-creation add column and rename column are supported as an MVP slice of epic #3226 (superseded by #3331) — with one deliberate constraint: column ids are stable and rename never migrates them.

Grammar

columnLabels is a labelDefs-style array of {id, label} objects, not a YAML-ish map keyed by id — an arbitrary column id can contain , : brackets, quotes, or #, and the parser has no quoted-key support. The array form reuses the same bracket-depth-aware object-pair machinery as labelDefs and views, so an id or label carrying those characters round-trips exactly like a labelDefs name does (quoted via quoteListItemIfNeeded when needed):

columnLabels: [{id: Backlog, label: "To Do"}, {id: "Q1, Q2", label: "Q1/Q2 Combined"}]

In memory, KanbanBoard.columnLabels is exposed as a Record<string, string> (id → label) rather than an array — a convenience overlay, not a second source of truth. It is emitted only when non-empty; a board that has never been renamed has no columnLabels key at all and stays byte-identical to a pre-#3332 manifest.

Id vs. label semantics

  • columns: string[] entries are always the stable id. A ## Heading in the manifest body, a card's status value, and a collapsed entry are all ids — never labels — everywhere in the codebase and in stored files.

  • columnLabels is display-only. getColumnLabel(board, columnId) (exported from @takazudo/kanban-parser) resolves a column's label: the overlay entry if one exists, else the id itself. Every UI surface that shows a column name to the user (column headers, collapsed tiles, swimlane lane headers, the card move-to menus, the calendar detail's Status: row) renders through getColumnLabel; every surface that acts on a column (drag target, move-to callback, groupBy, the filter bar, keyboard nav, now-view color logic, notify reconciliation) uses the id, unchanged.

  • When a column's label is set to a value equal to its own id, the overlay entry is deleted (normalized) rather than stored — the id is always trivially a valid label for itself, so no overlay entry is needed to represent "label equals id".

Zero-migration rename contract

Renaming a column changes only the columnLabels overlay — nothing else. A rename is a manifest-only write: planDirectoryWrites produces cardWrites: [] and cardDeletes: [], and every other part of the manifest — the columns array order, every ## Heading, every card's status, and every collapsed entry — is byte-unchanged. Concretely, renaming column id In Progress to the label "Doing Now" leaves ## In Progress as the heading, leaves every card in that column with status: In Progress, and (if the column was collapsed) leaves collapsed: [In Progress] untouched; only a columnLabels: [{id: In Progress, label: Doing Now}] entry is added to the frontmatter.

Add column

addColumn(board, name) appends a new stable id (the trimmed name) to columns. Empty columns are valid per the grammar above — addColumn does not seed a placeholder card. Both addColumn and renameColumn reject (no-op) an empty or whitespace-only value, a value containing a newline/control character (which would break the single-line ## Heading or columnLabels grammar), and a value that collides — case-sensitively — with any existing column id or label (the column being renamed excludes its own current id/label from that check).

Remove / reorder — deferred

Column remove and reorder are explicitly out of scope for this slice and are deferred per the #3226 decision — there is no removeColumn or reorderColumns op, and no UI affordance for either. A user who wants to reorder or remove a column today edits columns: in KANBAN.md by hand.

Pure-function adapter (locked signatures)

The translation between the stored files and the in-memory board lives in packages/kanban-parser/src/directory-model.ts as pure functions. These signatures are locked by this spec; S3 fills in the bodies (which currently throw new Error("not implemented")). All are re-exported from packages/kanban-parser/src/index.ts.

export const KANBAN_FILENAME = "KANBAN.md";

// Leading body H1, otherwise the supplied stable filename/in-memory slug fallback.
export function deriveCardTitle(body: string, filenameFallback: string): string;

// The KanbanBoard fields minus cards.
export interface BoardMeta { /* title, columns, collapsed, groupBy?, views?, labelDefs?, columnLabels?, cardWidth?, cardMode? */ }

// Manifest parse/serialize — frontmatter + per-column ordered card-file lists.
export function parseKanbanManifest(md: string): {
  meta: BoardMeta;
  columns: { name: string; cardFiles: string[] }[];
};
export function serializeKanbanManifest(board: KanbanBoard, fileIndex: Map<string, string>): string;

// Card-file parse/serialize. serialize preserves unknown frontmatter keys via prevContent.
export function parseCardFile(content: string, filename: string): {
  card: Omit<KanbanCard, "status" | "order">;
  idStable: string;
};
export function serializeCardFile(card: KanbanCard, prevContent: string | null): string;

// Archive operations and the central visibility boundary.
export function isActiveCard(card: Pick<KanbanCard, "archived">): boolean;
export function activeCards(board: KanbanBoard): KanbanCard[];
export function archiveCard(board: KanbanBoard, cardId: string): KanbanBoard;
export function restoreCard(
  board: KanbanBoard,
  cardId: string,
  columnId?: string,
  order?: number,
): KanbanBoard;

// Read: assemble a full board (+ fileIndex + warnings) from the manifest and all card files.
export function assembleBoard(
  manifestMd: string,
  cardFiles: { filename: string; content: string }[],
): { board: KanbanBoard; fileIndex: Map<string, string>; warnings: FormatWarning[] };

// Write: plan the writes for a board edit (manifest + card writes/deletes + updated index).
// prevContents maps filename → raw stored content for cards that already exist.
// It is used to preserve unknown frontmatter keys end-to-end (see Unknown-key preservation).
export function planDirectoryWrites(
  prev: KanbanBoard | null,
  next: KanbanBoard,
  fileIndex: Map<string, string>,
  prevContents: Map<string, string>,
): {
  board: KanbanBoard;
  manifest: string;
  cardWrites: { filename: string; content: string }[];
  cardDeletes: string[];
  fileIndex: Map<string, string>;
};

Notes:

  • fileIndex keys are idStable, values are filenames (e.g. "k7m2p9q4rs3t" → "ship-the-adapter.md"). It is the stored-identity table that resolves the rename trap — derive filenames from it, never from card.id.

  • serializeKanbanManifest writes the body link paths from fileIndex and the link text from each active card's current title. Archived cards are always excluded.

  • serializeCardFile round-trips unknown frontmatter keys from prevContent (prevContent === null ⇒ a brand-new file) while removing any stale title key; card.body is the authoritative document content.

  • planDirectoryWrites accepts a prevContents: Map<string, string> (filename → raw stored content). When serializing a card that needs writing, it looks up the prior content via prevContents.get(filename) ?? null and passes it to serializeCardFile. This is the end-to-end unknown-key-preservation guarantee: keys like created_at, updated_at, or project_code injected by the timestamp-writer layer survive every card edit without the kanban serializer having to know about them. Brand-new cards have no entry in prevContents (→ null → fresh frontmatter). Move/reorder-only changes produce no cardWrites at all, so keys are trivially preserved for those operations.

  • The returned board is the exact in-memory identity state represented by the planned files and manifest. In particular, titleless cards use the collision-free filename actually allocated by the plan rather than a provisional pre-allocation fallback.

  • The useKanbanDirectory hook builds prevContents from the raw card file contents read on load and passes it here. After each write the hook updates the map (written files get the new serialized content, deleted files are removed) so subsequent edits within the same session also preserve keys.

  • planDirectoryWrites allocates a stable filename via uniqueSlug for any card whose idStable is not yet in fileIndex, and emits cardDeletes for any idStable that was in prev/fileIndex but is gone from next. The filename never changes for an idStable already present.

  • assembleBoard performs the card + column reconciliation above and returns warnings; it never writes.

KanbanCard.title remains derived/read-only; producers mutate body, never title directly. Archive state is represented by the optional literal archived?: true, archivedAt?: string, and archivedFrom?: string fields described above.

Prior art surveyed

The chosen design — a flat directory + a KANBAN.md manifest, with one card per file — was selected after surveying how comparable markdown-kanban tools store a board. The storage models cluster into three families:

ToolStorage model
Obsidian Kanban (native, mgmeyers/obsidian-kanban)Single markdown file per board; ## Heading = column, - [ ] item list lines = cards inline in the same file.
Obsidian Kanban folder forks (community)Variations that explode cards into a folder of note files, linking back to a board note — the seed of the directory-as-board idea.
kanban-mdSingle markdown file; columns as headings, cards as list items (single-file family).
kanban-filesOne file per card in a folder; ordering/columns derived from folder + naming conventions rather than an explicit manifest.
SignboardFolder-of-files board where each card is a file; board structure inferred from directory layout.
TagSpacesFiles-as-cards using sidecar metadata + tags; "board" is a saved view over a tagged folder, not an explicit manifest.
simple-kanbanSingle JSON/markdown document holding the whole board (single-document family).
imdoneCards are markdown blocks / TODO-style comments found across files and code; lists/columns inferred from tags, no per-board manifest.
VS Code markdown-kanbanSingle markdown file per board; headings = columns, list items = cards (single-file family).

Rationale for flat-dir + manifest

  • Single source of truth for membership + order. A dedicated manifest (KANBAN.md) means column membership and card order live in exactly one place. A move or reorder is one manifest write — no per-card order: frontmatter to rewrite across N files, and no write-amplification when reordering a long column.

  • No ordering ambiguity. Tools that derive order from filename or scattered tags (kanban-files, Signboard, imdone, TagSpaces) must invent tie-break and conflict rules. An explicit ordered link-list has none: position is order, full stop.

  • One card per file = git/editor/sync-friendly. Each card is an independently diffable, externally-editable, syncable document — unlike the single-file family (Obsidian native, kanban-md, VS Code markdown-kanban, simple-kanban), where every edit churns one big file and concurrent edits collide. This matches zudo-text's Unix-philosophy framing (a board is a directory of plain files); users can use the External File Editor separately when they need to inspect local files.

  • Stable stored identity decoupled from display. Keying stored identity on idStable with a creation-time stable filename (vs. naming files after the mutable title) avoids the rename trap that title-named-file schemes hit, and keeps the notification registry key stable across renames.

The folder-fork and files-as-cards families pointed the way to directory-as-board; the explicit-manifest piece (borrowed in spirit from the single-file family's heading+list structure, lifted out into its own file) is what gives this design an unambiguous, low-write-amplification source of truth.

Initializing a board

A directory becomes a kanban board the moment it contains KANBAN.md. There are three initialization flows, all of which funnel through the shared createKanbanBoard helper in tauri-app/renderer/lib/kanban-create.ts.

Kanban Setup Wizard (primary flow)

The Setup Wizard is the primary initialization flow. It is shown inside the frame when a user chooses "Kanban Board" from the empty-frame picker (core.empty provider → EmptyFrameNav → configure pane). The wizard component is KanbanSetupWizard (tauri-app/renderer/view-providers/kanban-setup-wizard.tsx) and is wired to kanbanBoardProvider.SetupComponent.

The wizard has four steps:

  1. Location — New or Existing.

    • New: the user names a folder and optionally picks a workspace parent folder (default: the workspace root) via the shared WorkspaceTreePicker (S7/#4211).

    • Existing: the user picks from the workspace-enumerated list of boards (listWorkspaceKanbanBoards, which scans workspace keys for */KANBAN.md), or browses the workspace with the same picker. An already-initialized board (has KANBAN.md) opens directly (skips details).

  2. Details — board title and comma-separated column names, via KanbanBoardDetailsFields (shared with the in-place setup CTA).

  3. Layout — Board (list), Now, or Calendar.

  4. Confirm — writes the manifest unless the folder is already a board, then calls onCommit({ directoryPath, layoutId }). There is no directory-creation step: the manifest write is what creates the workspace directory.

The wizard guards against manifest-write failures: onCommit is never called unless the post-write listing confirms KANBAN.md is present. A request-token mechanism cancels in-flight commits if the user navigates away (Back/Escape) while the async create is pending.

"Set up this board" in-place CTA

If a core.kanban-board frame is opened with a directoryPath that points at a directory without a KANBAN.md (e.g., a pin pointing at a newly-created directory, or a path typed manually), the board content area shows a "Set up this board" form instead of the board view. The user fills in title and columns, clicks the button, and the form calls createKanbanBoard in-place. The frame then reloads via a reloadKey increment (remounts useKanbanDirectory) and the board view appears. This is implemented in the SetupThisBoardCta component inside kanban-board-provider.tsx.

"New Kanban Board…" (palette command)

The palette entry opens the shared WorkspaceTreePicker in directory mode. On selection it calls createKanbanBoardIfMissing — so picking a folder that is already a board opens it as-is rather than overwriting its manifest — and then openKanbanFrame. The handler lives in write-page.tsx.

This replaced the retired local-folder conversion command, which crossed the workspace/local-files storage boundary (see "Boards live in the workspace" above).

Canonical creation helper

All three flows call createKanbanBoard({ dir, title, columns }) from tauri-app/renderer/lib/kanban-create.ts. This function builds a KanbanBoard object, serializes it via serializeKanbanManifest, and writes the result to the workspace key ${dir}/KANBAN.md via bridge.workspaceFiles.write. It is the one canonical path that creates a manifest — no other renderer code brings a KANBAN.md into existence.

Creation is not the only thing that writes the manifest, though. An existing KANBAN.md is rewritten by ordinary in-board edits (add/move/reorder/archive a card, rename a column) and by the out-of-board card-add path described next. Those update a manifest that already exists; they never create one.

Write path and repair semantics (workspace)

useKanbanDirectory is the only mounted writer. One user mutation becomes one applyBoardChange, debounced 400 ms, flushed on unmount / directoryPath change, and serialized through a module-level per-directory queue that the out-of-board card-add path (runKanbanDirectoryWrite) shares. waitForKanbanDirectoryWrite(dir) lets a caller that needs the directory quiescent — the un-boarding flow, which deletes KANBAN.md — await writes whose debounce has already fired, which cancellation cannot stop.

There is no cross-file atomicity, and none is claimed

lib/kanban-board-commit.ts writes the changed card files, then the manifest last. That ordering is the load-bearing half of the repair story: a card written without its manifest entry is an orphan, which the reader reconciles on the next load; a manifest written without its card would be a dangling link, which it cannot. Residual partials are healed by manifest-last ordering, caller baseline discipline (persistedBoardRef only advances on a write that actually landed), and repair-on-next-write. Nothing here provides a transaction (#3345).

The staging pass is gone (S14/#4218)

Pre-pivot, every payload was written twice — into <boardDir>/.kanban-staging/<stamp>/ and then over the live file — and the staged copies were deleted afterwards. That proved the batch was writable for filesystem failure modes (permissions, ENOSPC, the directory deleted underneath) before touching live files. None of those apply to the workspace, and staging is actively harmful there:

  • The workspace records a server version row per key per write (D1's version-row invariant, 1 操作 = 1 書き込み). Staging tripled the writes for every board edit, which makes Note History and Checkpoints unreadable.

  • .kanban-staging/ is not in D4's namespace table, so it would classify as notes and replicate to every other device as junk.

The guarantee staging actually bought — prove the batch is addressable before touching live keys — is preserved by validating every target key up front through joinWorkspacePathnormalizeWorkspacePath. That is the only deterministic, per-path way a workspace write can be refused; anything that gets past it fails the whole batch identically (workspace not armed), so it cannot produce a partial commit that validation would have caught.

The commit also skips a payload whose stored content already matches byte-for-byte, per the version-row invariant. The planner already filters unchanged cards, so in practice this catches the manifest, which the write path otherwise rewrites unconditionally — a no-op drag would add a manifest version row on every gesture.

Remote changes reconcile the baseline

A sync pull can land another device's manifest or card edit while a board is mounted. The hook subscribes to workspaceFiles scoped to its own directory and, on a change whose origin is "remote" and whose key is a direct-child .md, re-reads through the write queue.

What that re-read must advance is the baseline (persistedBoardRef / cardContentsRef) that planDirectoryWrites diffs against. Staleness there is not merely a display bug: the next local edit would plan against content the workspace no longer holds and overwrite the remote edit on its next write.

The visible board then branches on whether an optimistic edit is outstanding — which the hook decides by reference-comparing board against the baseline snapshot, since board is only ever set to the very object persistedBoardRef holds:

  • Nothing outstanding → the re-read board is adopted wholesale. Anything narrower (e.g. appending only the cards this device had never seen) leaves the other device's card edits, reorders and deletes invisible, and the next unrelated local mutation then plans from the fresh remote baseline toward that stale UI state — overwriting the remote content after all.

  • An optimistic edit outstanding → a three-way merge against the baseline the edit was made against (renderer/utils/kanban-remote-merge.ts). Per card, matched by idStable: untouched → take the remote version; edited by the user → keep theirs; new remotely → add; deleted by the user → stay deleted; deleted remotely → follow the delete only if the user has not edited it (local content is never lost to another device's delete). Board metadata (title / columns / collapsed / display prefs) moves as one unit because those fields reference each other's column ids. The pending debounced write's board gets the same merge, so its eventual write plans from the fresh baseline without reverting the remote changes.

Because cards and metadata can come from opposite sides, the merge's last step re-homes any non-archived card whose status is not a column of the merged board (into the first column). Without it, a card the user was editing in a column the remote renamed or deleted is rendered nowhere AND omitted from the serialized manifest — silently demoted to an orphan on the next read.

origin: "local" changes are ignored. Every write this hook makes emits one, so reacting to them would re-read after each of our own writes and could ping-pong through the queue.

A remote deletion has two outcomes. With no pending local edit, deleting only KANBAN.md clears the visible board and selects notABoard; deleting every key under the board path clears it and selects dirMissing. Both cases also discard stale file/card baselines, warnings, and indexes. With a pending optimistic edit, the local board remains visible and neither empty state is selected. Its queued write treats the remote board as an absent disk baseline, so the debounce recreates KANBAN.md and every card file instead of losing the local edit or leaving unchanged cards missing after a whole-directory deletion.

Pop-out windows do not have this — or any — workspace yet

A popped-out frame is a separate WKWebView with its own empty module singletons, andbootstrap/tauri.tsx deliberately skips AppBoot there, so a popped-out board has no armed workspace to read from at all. That is a known gap in the pivot, decided in epic #4204 D13 and implemented by S18 (#4222) — not something the board layer can fix on its own.

Card deletes route through the workspace trash

A confirmed card delete is a content-destroying operation, so the card is MOVED into the workspace's .trash/ under a YYYYMMDD-HHMMSS__<original> name instead of being deleted (lib/kanban-trash.ts). Trashing runs before the manifest write so a trash failure aborts the whole apply, and it is two-phase: every trash copy is staged before any original is deleted. Without that split, an earlier card would already be gone when a later card's copy failed, leaving the (never-rewritten) manifest referencing a missing file.

.trash/ is synced now (D4; the three push-choke-point guards were removed in S9/#4213). Surviving a restart and a device switch is the point of the change.

Resolved — offline trash no longer resurrects the original

Trashing is an upsert into .trash/ plus a delete of the original, and both halves are now durable across a restart: SyncOutbox carries delete tombstones with the same seal-before-network persistence as upserts (#4237, the Durable Tombstones epic). Trash a card offline and restart before reconnecting, and the queued tombstone replays — the original stays deleted. The broader local-durability gaps (cold-boot-requires-network, offline-created-file invisibility) are also resolved now, by the encrypted local workspace mirror (#4233, shipped via epic#4808) — seeEncrypted Local Workspace Mirror. A cold boot with no network now opens the full workspace (boards included) read/write off the local mirror instead of failing.

A best-effort retention sweep (30 days / 200 entries) fires once per hook mount after the first successful board load. It counts parseable names only, so a foreign file that ends up in .trash/ is never deleted and never consumes a cap slot.

Adding cards from outside the board (palette send)

A note can be sent into a board without that board being mounted — the command palette's "Copy to Kanban" flow, offered on the Note Tray (both the Inbox and Archives trays). The user picks board → column → placement; the placement leaf dispatches the typed add-note-to-kanban event, and the owning note-tray provider instance executes it (snapshots its live note body, then writes the card). Routing the executor through the provider instance — not a page-level listener — is what lets it read the exact note the tray has selected, numbered draft or named file alike.

The write itself goes through addCardToBoardDirectory({ dir, columnId, body, placement }) in tauri-app/renderer/lib/kanban-add-card.ts. Unlike the mounted board (whose useKanbanDirectory hook owns an in-memory board it mutates and debounce-saves), this path has no board object in hand, so it:

  1. Reads the live board from dir (readKanbanBoardFromDirectory) — this is the baseline the write is planned against and the manifest that gates column validity. A missing/removed KANBAN.md throws KanbanBoardMissingError; a since-removed target column throws KanbanColumnGoneError (the card is not silently dropped).

  2. Appends the card (addCard), reorders it to the top for placement: "first", and plans the writes with planDirectoryWrites.

  3. Commits the plan via the shared board-commit path: the manifest (KANBAN.md) plus one new card file are written into dir.

The whole read-modify-write runs inside the shared per-directory write queue (runKanbanDirectoryWrite), so it is serialized against a mounted board's debounced writes and against a concurrent add on the same directory — no interleaving on a stale read baseline. After committing it bumps the directory's external-write revision and dispatches kanban:directory-external-change, so a board that is mounted for dir re-reads and merges the new card without a reload (see use-kanban-directory).

The palette send is a copy: the source note is never modified or removed by this path. (A "Move to Kanban" variant that removes the source after a successful add is layered on separately; the add-to-board mechanics above are identical for both.)

  • Architecture: Philosophy — the Unix-philosophy framing (a board is a directory of plain files).

  • Cloud-Primary Storage — D1 (the workspaceFiles surface and the version-row invariant), D4 (workspace-relative addressing and the .trash/ namespace), D5 (why bridge.files stays local-only), D7 (the offline durability guarantees above, now including cold boot).

  • Backend Bridge — the bridge namespaces, including workspaceFiles (board I/O) and the local-only files.*.

  • packages/kanban-parser/src/types.ts — the stable KanbanBoard / KanbanCard in-memory shape, including the derived/read-only KanbanCard.title convenience.

  • packages/kanban-parser/src/directory-model.ts — the locked pure-function signatures (S3 implements the bodies).

  • tauri-app/renderer/lib/kanban-create.ts — shared createKanbanBoard helper (the single canonical manifest-creation entry point for all three flows).

  • tauri-app/renderer/lib/kanban-add-card.tsaddCardToBoardDirectory (the out-of-board card-add write path) + buildCardBodyFromNote.

  • tauri-app/renderer/view-providers/kanban-setup-wizard.tsx — the primary initialization wizard wired to kanbanBoardProvider.SetupComponent.

  • tauri-app/renderer/lib/kanban-board-commit.ts — manifest-last commit + the unchanged-key skip (and why the staging pass was retired).

  • tauri-app/renderer/lib/kanban-trash.ts — workspace .trash/ routing and the retention sweep.