zudo-text

検索したい単語を入力

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

Format-aware TODO item tools

Lossless item edits, exact-document authority and original-base UI writes for zudo-text TODO documents (#7487).

This is an unreleased source capability. It reuses the restricted connection principal, grant revision, key lease and PAT machinery described in Restricted Kanban external access, adding a separate TODO resource family. It does not change Kanban grants.

Document format and the lossless planner

The tools operate only on existing zudo-text TODO documents: type: todo frontmatter (legacy todo: true is read but never rewritten), at most one ## TODO and one ## DONE section, - [ ] / - [x] item lines with an optional <!-- id: <12 base32> --> identity, and two-space-indented sub-lines (an optional first notify: line, then the body).

packages/todo-parser/src/source-document.ts is the only code path the tools use. It does not round-trip source through serializeTodoMarkdown, which reorders frontmatter, drops legacy keys and re-emits sections:

  • readTodoSource(source) analyzes the bytes, cross-checks every item against parseTodoMarkdown, and returns items with their persisted idStable or null. It never assigns identity or produces content.

  • planTodoCommand(source, command, allocation) splices only the targeted item's bytes (or appends a new item), re-analyzes the result, and proves the frontmatter, preamble, extra sections and every untouched item are byte-identical. Any failure throws a TodoSourceError with no output.

Refused with zero writes: CRLF, a non-blank line inside TODO/DONE that is not an item or indented sub-line (loose prose, * bullets, tab indentation), a checked item in TODO or an open item in DONE, duplicate TODO/DONE headings, duplicate identities, and a body whose first line the parser would promote into a notify: spec. Commands: create (append to TODO, one allocated identity), update (explicit title/body), set_done (move between sections; the current state is a no-op), reorder (within the item's section) and initialize_ids (append identities only where missing). There is no delete or archive.

Authority

A connection grant row (kanban_grants) carries exactly one resource family: Kanban scopes, or todo:read / todo:write. Mixed grants never authorize. todo_document_grants binds a grant to exact files by file_id, an HMAC of the exact path in its own domain (zudo-todo-location-v1), and the file lifecycle revision from kanban_manifest_lifecycles (which tracks every file row), so a move, delete or recreate ends the grant for that document.

todoAuthorityPredicate (in workers/sync-server/src/todo/authority.ts) requires the grant id/user/client/workspace/revision, active status, unexpired grant and admission, the workspace encryption generation, the exact binding and lifecycle, and TODO-only scopes. It is evaluated at admission, embedded in the operation reservation and in the final commit statement, and re-checked before returning any read or replay. Generic documents, search, history, checkpoints, assets and agent:invoke stay unreachable: hasScope grants a restricted principal nothing outside its resource scopes, and full PATs cannot carry todo:*.

The lease holds the whole workspace's keys in Durable Object memory. The per-document limit is server authorization, not file-level cryptographic isolation. Keys never reach the model, and OAuth refresh never extends a lease.

Mutation commit

executeTodoCommand derives a scope key from the grant/document lifecycle and the caller's idempotencyKey, and a fingerprint from the expected revision and command. A repeated key returns the stored receipt after re-authorization; the same key with another payload fails with IDEMPOTENCY_CONFLICT. The command is validated before reservation; created identities are derived from the reserved operation id, so a replay allocates the same id.

One D1 batch inserts the receipt only when the authority predicate, the exact observed head (id, path, version, hash, size, lifecycle) and the staged blob all hold; the head update, file_versions row and changes row are guarded by that receipt's admission nonce. A no-op command commits a receipt without a new version. Receipts contain no titles, bodies or paths. The revision is whole-document: concurrent edits of different items conflict and must be reread.

Original-base UI writes

The TODO board writes through makeMarkdownFileProvider's opt-in revision seam (packages/view-provider/src/make-markdown-file-provider.tsx). The base is captured from workspaceFiles.snapshot together with the exact text it describes — at load and on each local acceptance — and every debounced window is written with that base and expectedContent, never a base re-captured at flush. The captured outbox entry keeps the original base through offline, relaunch and reconnect. A conflicting source is retained for Review changes by the workspace store; the board then stops writing behind a Reload banner. Providers that do not opt in (mindmap) keep the plain read/write contract.

Human edits still serialize the whole document with serializeTodoMarkdown; only the tools are byte-preserving.

Settings connection surface

Settings → External Connections renders TodoConnectionsPanel (tauri-app/renderer/components/settings/sections/todo-connections-settings.tsx) as its own list below the Kanban connections. It talks to the management routes through TodoConnectionClient (@takazudo/kanban-api/first-party-todo), wired in tauri-app/renderer/lib/todo-connections.ts onto the same guarded first-party session as the Kanban client (beginConnectionSession), so the account, workspace, encryption-generation and backend checks are shared. The first-party clients live next to the Kanban one in kanban-api rather than in backend-bridge, because they derive raw keys and must stay out of any tool registry.

  • Document picker: the client decrypts catalog paths locally and offers Markdown files (no KANBAN.md, nothing under .zudotext/). On Connect, each selection goes through POST /todo/selection, which decrypts the file and refuses anything without TODO frontmatter. The UI names the refused file. A changed lifecycle revision is reported as changed, never silently re-bound.

  • Scopes: read-only by default; edit permission (todo:write) is an explicit checkbox. Connect starts locked. Unlock reserves a revision first and locks it again if enrollment fails.

  • Validity: GET /todo/connections reports valid per document. A moved, deleted or recreated file shows as Invalid; Unlock and token creation are disabled and the lease is never reported as unlocked.

  • Tokens: a minted token is held only in component state while the token form is open and is cleared on Done/close.

  • Each grant card has a designed context menu (Lock, Disconnect, Refresh, Help).

Remote OAuth

TODO OAuth reuses the Kanban issuer, provider, token endpoint, consent pages, kanban_grants and kanban_oauth_bindings. workers/sync-server/src/kanban/oauth.ts declares one protected resource per connection family — Kanban at /api/v1/kanban/mcp, TODO at /api/v1/kanban/todo/mcp — and resourceFamily() maps an exact canonical resource to its family. An omitted resource still defaults to Kanban (defaultResource), so existing Kanban clients are unchanged.

The token audience is the only family selector, and every other input must agree with it:

  • Authorize. /oauth/authorize derives the family from the provider-parsed request's canonical resource. All requested scopes must belong to that family; a mixed set, a foreign set, or TODO scopes on the defaulted Kanban resource fail before a consent handle exists. The page renders data-family, which only chooses the browser flow.

  • Consent. /oauth/consent consumes the single-use, browser-bound handle, then re-derives the family from the approved request — never from the body — and parses the body with that family's schema. A TODO consent calls createTodoGrant with exact documentSelections from /oauth/todo/selection (getTodoDocumentSelection); submitted scopes must be a subset of the requested ones. The grant starts locked. If completeAuthorization fails, the grant is revoked with changeTodoGrant; the browser's late-cancel compensation uses the revoke-only PATCH /oauth/todo/connections/:grantId, and neither family's revoke route accepts the other family's grant.

  • Token issue and refresh. tokenExchangeCallback requires the audience's family to equal the bound grant's family and the OAuth grant's scopes to be that family's, so a forged cross-family binding never produces a token. Refresh narrows scopes only, and never unlocks, renews a lease or changes the selected documents. RFC 7009 revocation uses the shared kanban_grants barrier and lease disposal.

  • Resource. authenticateTodo validates the TODO audience, requires the bound grant and the token scopes to be TODO-only, and narrows the principal's scopes to the token's. authenticateKanban does the same for Kanban, so neither token family authenticates on the other's routes. Restricted TODO PATs are unchanged. 401 and 403 responses name the TODO protected-resource metadata in WWW-Authenticate.

Explicit unlock stays first-party: an OAuth-created TODO grant appears in Settings → External Connections and is unlocked there.

Routes

  • Tools: POST /api/v1/kanban/todo/tools/<tool_name> and Streamable HTTP MCP at /api/v1/kanban/todo/mcp, both authenticated by a TODO-only restricted PAT or a TODO-audience OAuth access token.

  • OAuth: protected-resource metadata at /.well-known/oauth-protected-resource/api/v1/kanban/todo/mcp; consent helpers POST /oauth/todo/selection and PATCH /oauth/todo/connections/:grantId (revoke only), alongside the shared /oauth/authorize, /oauth/consent and /oauth/token.

  • First-party management (interactive sessions only): /api/v1/kanban/todo/selection and /api/v1/kanban/todo/connections[...].

  • Local stdio: ZUDOTEXT_MCP_PROFILE=todo in @takazudo/zudotext-mcp.