19. Kinds and tags editable over MCP, made safe by metadata revisions#
Date: 2026-10-22
Status#
Accepted. Revises 13. The MCP server is part of the app: streamable HTTP, add-only writes, which deferred changing an existing page’s kind.
Context#
write only adds pages or appends to Claude’s own, so Claude could not fix the kind or tags of
the user’s pages (e.g. imported notes that are really whiteboards). The add-only rule was the
safety: a session steered by injected text could not destroy anything. Kinds and tags are cheap
to get wrong, so letting Claude change them needs a way to see and undo every change.
Decision#
MCP gains
edit_meta(kind and tags of named pages, or of every page a search finds, with adry_runand a page limit),rename_tag(nested tags too; the same limit; merging into a tag in use only withmerge; never into its own child),revisionsandrevert. Still no delete, and no tool changes the title or text of a page the user made. History is reached over MCP only: no PWA view or HTTP revert endpoint.Every change to a page’s kind or tags, by Claude or in the PWA (
PATCH /api/pages), is a row of a new table,page_meta_rev: author, source (mcporweb), the kind, whether it was fixed (kind_fixed) and tags before and after, the default fields a new kind added, the revision a revert undid, and a batch id. Full snapshots of kind and tag names: a few bytes, never a copy of the text or of a file, so no delta chains are needed. Text keeps its own full revisions inpage_rev, unchanged (itsrevis the PWA’s edit token).Model-derived data (summaries, image descriptions, ingest’s tags, the kind task boxes give a note) is not a revision: it can be made again.
Coalescing: a change by the same author and source within
SCRAP_REVISION_WINDOWseconds (default 300) of the page’s latest revision being made folds into it, if nothing else changed the page’s kind or tags between; a fold that ends where the revision started drops it. A revert, and any change in a batch, never folds, nor is folded into: a batch’s revisions stay exactly what that one call did, so the batch can be undone as a whole.revertrestores the whole snapshot from just before a revision (kind, fixed or not, tags) and takes off the defaults its kind change added unless changed since, as a new revision: a page Claude filed and then reverted follows its task boxes and ingest again. Being a whole snapshot, it also drops unrecorded (derived) tags and kinds added since;dry_runshows this.Batches: an
edit_metaof a query or of several pages, and everyrename_tag, stamps its revisions with one batch id;revert(batch=...)undoes them in one transaction as a new batch, itself undoable at once. If any page of the batch changed kind or tags since, the batch revert is refused, saving nothing (never a silent clobber); its pages can still be reverted one by one. Pages in the trash are skipped.Revision ids are
AUTOINCREMENT: a dropped burst’s id is never handed to another page’s revision, since tools give ids out andreverttakes them back.Kinds are the fixed set the schema allows (
note,todo,consume,whiteboard,document, with the PWA’s namesread,board,doc), not free-form.edit_meta’sfieldsalso set a todo’sdue,priorityandsnoozed_until(#385; refused on other kinds, asstatusis: the check is of the page’s kind before the call, so making a page a todo and giving it aduetakes two calls). They live inpage_todo(ADR 36), notfields, but are recorded in the revision (fields_added,fields_was) as fields are, sorevertundoes them.edit_meta’sfieldsare onlymedia_type,status,due,priorityandsnoozed_until(#386): any other name is refused, not stored as a meaningless field.revertstill undoes an older revision that set one.A kind or tag edit does not change
updated_at, as the PWA’s never did. A kind given toedit_metais fixed even if it is the page’s kind already, as the PWA’s PATCH does.Migration 8 is additive, as 6 and 7: the table is made only if missing, so an older scrap still runs with
user_versionset back to 7. No backfill: a page’s first change records its prior state.
Consequences#
Claude can tidy kinds and tags in bulk; a mistaken or injected change is visible and revertible from
revisions, and reverting is itself undoable.page_meta_revgrows with every edit burst; rows are independent snapshots, so old ones can be thinned later without rewriting others. Not built yet.No PWA view of revisions yet: they are reached over MCP.
Deploying over a database already at version 8#
Superseded in part by ADR 37: migrations 8 and 9 are folded into the baseline and 1.0 refuses a store below schema 18, so this recipe (setting
user_versionback to 7) no longer applies. It is kept as history.
A local build of other work (text enrichment) also numbered its migration 8, and a canary
database already at user_version 8 from it would skip this one: then page_meta_rev is
missing and every tag or kind edit, PWA’s too, fails. Both migrations are additive and
idempotent (IF NOT EXISTS), so before the first deploy of any build containing both, set
PRAGMA user_version = 7 on that database, and renumber whichever lands second to 9.
As merged, this change is migration 8 (add_meta_revs) and text enrichment
(20. Text enrichment: Claude tags, summarises and relates text pages, apart from the text) is migration 9 (add_enrich). A database at version 8 made by the
merged code is simply migrated to 9 on its first start. The workaround above applies only to a
database numbered 8 by the earlier local build: with user_version set back to 7, the next start
runs migrations 8 and 9 in order, and each leaves tables that already exist alone.
A database that already has a page_meta_rev table from an earlier revision of this change
(only throwaway test databases: the canary has none) must have it dropped first (DROP TABLE page_meta_rev): migration 8 then fails loudly on the missing columns, or, at version 8, is
skipped and inserts fail.