ADR 017: Page-level tags, and how a journal becomes `#Journal`
Date: 2026-09-11. Status: accepted.
Context#
A block becomes a task by carrying a marker, and ADR-less precedent (see apply-ops.ts's
rebuildRefRows) now has every marked block emit a derived tag ref to the Task page. That
works because refs are per block: ref.src_block_id is NOT NULL and every consumer joins
through block.
Journals are different. "This page is a journal" is a fact about the page, and there is
nowhere to put it. The same is true of any page-level tag a person might want — Logseq's
tags:: page property, which is how you say "this page is a Person / a Project / a Book".
Three things want this at once:
#Journalon daily pages, so journals are reachable through the same machinery as anything else rather than only through the journal stream's bespokejournal_dayquery.- User-set page tags (
tags:: person, czech), which the importer already reads from Logseq graphs and which currently only survive as an inertpage_proprow. - A place for "pages tagged X" to come from, so a tag page can list them the way a page lists its backlinks.
Decision#
A derived page_tag index, maintained on write, alongside ref rather than inside it.
CREATE TABLE page_tag (
page_id TEXT NOT NULL REFERENCES page(id),
tag_key TEXT NOT NULL, -- normalizePageName of the tag
tag_page_id TEXT, -- the tag's own page, when one exists
source TEXT NOT NULL CHECK (source IN ('property','intrinsic')),
PRIMARY KEY (page_id, tag_key)
) WITHOUT ROWID;
CREATE INDEX page_tag_key ON page_tag(tag_key);
Two sources feed it, and the distinction is the point:
property— from the page'stagsproperty, split on commas with[[…]]unwrapped, the same shapeextractRefsalready applies to a block'stags. User-settable and user-removable.intrinsic— derived from a fact the page already carries. Today exactly one rule:journal_day IS NOT NULL⇒Journal. Not removable, because removing it would not mean anything: the page either has a journal day or it does not.
page.backlinks gains a tagged_pages group, so asking about Journal or Person returns the
pages carrying that tag next to the blocks that link to it. (Built 2026-09-13, B-111:
page.backlinks returns tagged_pages + tagged_total, windowed by the same limit/cursor as
linked, and the References panel shows "Pages tagged X" above linked references. The
remove-control distinction in Consequences is still not built — the panel lists both sources
alike.)
Why not the alternatives#
Relax ref.src_block_id to nullable and put page refs there. One table, uniform "what points
at X". Rejected: every existing query joins ref to block and would silently drop or
mis-handle page-sourced rows, and the ones that do not would need auditing for a constraint that
exists precisely to keep them honest. Weakening a NOT NULL that six queries depend on, to add a
different kind of row, buys uniformity at the cost of making the existing rows less trustworthy.
Store nothing; special-case Journal at read time. Cheapest, and it is what a bolted-on
version would have looked like. Rejected because it answers exactly one question and generalises
to nothing: user-set page tags would still be inert, and every future tag query would be another
special case in page.backlinks.
Write tags:: Journal into each journal page's properties on creation. This is closest to
what Logseq does. Rejected for the same reason the Task tag is not written into block text: it
puts one fact in two places that can disagree. Delete the property and you have a page with a
journal_day that is not a Journal; import a graph whose journals lack the property and they are
not Journals either. Deriving it means the question "is this a journal?" has one answer.
Consequences#
- A schema migration (version 3 → 4) adding the table and backfilling it. The table is derived, so
the backfill is a rebuild, not a data conversion, and
nooklet verifykeeps working unchanged because it compares live state rebuilt from the op log — derived indexes are not part of that comparison. page_tagmust be rebuilt whenever a page'stagsproperty orjournal_daychanges, which means the page-write path gains a hook exactly likerebuildRefRows.- The client has no
page_tagtable (sql-schema.md rule 1: derived tables are server-only), so "pages tagged X" is an API call, like search and backlinks already are. - UI can distinguish the two sources: a
propertytag is removable, anintrinsicone is shown but not offered a remove control. This is what makes Logseq's little×on a tag chip honest — there is nothing sensible for it to do onJournal. - The obvious next users of this table are a tag page listing its members, and filtering the all-pages view by tag. Neither is required by this decision.
Not included#
Page icons/emoji (page_prop.icon) are a separate, simpler thing: a single displayed property,
no index needed. Mentioned only because it arrived in the same conversation.