Plan: Page Tags (v1)
Status: Approved for implementation — handed to an implementing agent.
Scope owner: operator (design reviewed 2026-08-15).
Prerequisite reading: AGENTS.md (locked v1 decisions, do-not-regress list), MEMORY.md (decision register D01–D64), docs/architecture.md.
This document is the complete specification. Where this plan and older docs disagree, this plan wins for tag behavior; for everything else the existing locked decisions win. If you must deviate, record the deviation in MEMORY.md and this file's changelog.
1. What we are building
Every page can carry an ordered array of up to 5 tags. Tags are short, single-token, lowercase labels (e.g. agent-pages, research, kubernetes). The two product uses:
- Ledger pills — tags render as small pills on each row of the public ledger (
/and/page/{n}) and on/searchresults. Every tag of a page is shown. - Tag search —
/searchaccepts up to 10 tag filters alongside (or instead of) the existing fuzzy title query. Selected tags render as removable pills in the search UI; adding a tag uses a text input backed by a native<datalist>of the existing vocabulary. No JavaScript — all pill interactions are server-rendered links and GET forms.
Agents choose tags at publish time. The MCP exposes the existing vocabulary via a new list_tags tool so agents can prefer existing tags and only mint new ones when nothing fits. The convention (documented in the MCP tool description, not enforceable server-side) is that the first tag is the repo or top-level folder name the artifact was produced from — the MCP server is a thin HTTP client with no filesystem context, so the calling agent supplies this.
2. Locked decisions for this feature
These extend the decision register. When implementation lands, append them to MEMORY.md as D65–D71 (adjust numbers if the register moved) and update AGENTS.md accordingly.
| # | Decision | Rationale |
|---|---|---|
| T1 | Tag grammar: after NFKC → casefold, a tag must match ^[a-z0-9][a-z0-9._-]{0,31}$ (1–32 chars, ASCII only, starts alphanumeric; -, _, . allowed as separators). Anything else → validation error. Never silently strip/split. |
Repo/folder names contain -, _, . (next.js, my_notes). The operator's initial 20-char cap was raised to 32 because real repo names exceed 20 (agent-artifact-hosting = 22, research-agent-artifact-hosting = 31). ASCII-only keeps pills unambiguous in ?tag= URLs and avoids Unicode-confusable vocabulary entries. |
| T2 | Tags are canonical lowercase — Agent-Pages and agent-pages are the same tag; stored and displayed lowercase. |
Controlled vocabulary; case variants would fragment it. |
| T3 | Per-page cap 5, order-preserving; duplicates (after normalization) in one request → 422. | Operator constraint. Agent-chosen order is the display order. |
| T4 | Tags are page-level and mutable via PUT /v1/pages/{id} (full replace), not per-version. Same shape as title. No tags-only PATCH endpoint in v1. |
Reusing PUT means every tag mutation rides the transaction that already bumps the search corpus generation atomically (do-not-regress #23). A metadata-only PATCH is a v1.1 follow-up. |
| T5 | Multi-tag search is OR (match any), max 10 tags per query. Tags-only results rank by (tag_match_count desc, updated_at desc, id desc). With a fuzzy q, rank by (score desc, tag_match_count desc, updated_at desc, id desc). |
Forced by arithmetic: pages have ≤5 tags, so AND of 6–10 tags can never match. |
| T6 | Tag matching is exact (canonical name), never fuzzy, and tag names never enter the RapidFuzz title corpus. Tags are a SQL prefilter applied before scoring. | Preserves "search titles only" (do-not-regress #25) and avoids re-litigating the D62–D64 admission rules. |
| T7 | Tag search UI is JavaScript-free: removable pills are links (same URL minus that tag), adding uses a GET form with hidden inputs carrying current state plus a <datalist>-backed text input. |
D58 (no-JS search) and the ledger's zero-JS design are locked. Native <datalist> gives autocomplete without scripts. |
| T8 | Tag vocabulary is public. Pills appear on the public ledger for every active page, including PIN-locked ones (same as titles today; PIN protects the body only). tags table rows may orphan after purge; the vocabulary is always computed by joining active pages, so orphans are invisible. Purge does not bump corpus generation (unchanged). |
Consistent with D46. Orphan rows are tiny; GC is unnecessary. |
| T9 | MCP does not fail closed on tag version-skew. If an older API ignores tags (response omits/differs), MCP appends a warning line to the success message instead of raising. |
The PIN/expiry fail-closed checks guard security/lifecycle hazards; ignored tags are cosmetic. |
| T10 | Tag creation is implicit and publish-gated. Unknown (valid) tags are created inside the create/update transaction. No separate tag CRUD. Vocabulary growth is bounded by the publish bearer + 25/hour publish limit. | No new abuse surface, no new admin surface. |
3. Data model
New Alembic revision 0003_page_tags in app/alembic/versions/ (down_revision = 0002_search_corpus_state). Downgrade must cleanly drop both tables (one-revision-back rollback support, do-not-regress #6).
tags
id INTEGER PK AUTOINCREMENT
name VARCHAR(32) NOT NULL UNIQUE -- canonical lowercase, indexed via unique
created_at DATETIME(timezone) NOT NULL server_default now()
page_tags
page_id UUID NOT NULL FK -> pages.id ON DELETE CASCADE, indexed
tag_id INTEGER NOT NULL FK -> tags.id, indexed
position INTEGER NOT NULL -- 0..4, display order
PRIMARY KEY (page_id, tag_id)
UNIQUE (page_id, position)
SQLAlchemy models go in app/models.py (Tag, PageTag); add a tags relationship on Page ordered by PageTag.position (association object or secondary + order_by; keep it simple and explicit). SQLite FK pragma is already ON (D18), so the cascade works.
Not a JSON column on pages: both product uses (indexed exact-match filtering; a fetchable vocabulary with counts) want normalized rows.
page_deletion_logs / purge (app/services/purge.py): no changes. page_tags rows die with the page via cascade; tags rows persist harmlessly.
4. API changes (/v1, all publish-bearer authed)
4.1 POST /v1/pages
PageCreateRequest (app/schemas.py) gains:
tags: list[str] = Field(default_factory=list, max_length=5)
Validator: normalize each entry (NFKC → casefold → strip), enforce T1 grammar, reject duplicates post-normalization. Error messages must name the offending tag and the rule ("Tag 'My Tag' is invalid: tags are 1-32 chars of [a-z0-9._-], starting alphanumeric, no spaces").
Inside create_page (app/services/pages.py): after the page row is added and before bump_search_corpus_generation, resolve/insert Tag rows and add PageTag rows in the same transaction. Tag upsert must tolerate a concurrent insert of the same name (catch IntegrityError → re-select; or INSERT OR IGNORE + select). Keep the existing R2-compensation structure untouched.
4.2 PUT /v1/pages/{id}
PageUpdateRequest gains tags: list[str] | None = None (same validator). Semantics: None/omitted → preserve existing tags exactly; present (including []) → full replace. Replacement happens in the same transaction as the conditional UPDATE ... WHERE current_version = expected gate, after the rowcount check succeeds — delete existing PageTag rows, insert the new set. The existing bump_search_corpus_generation call already covers cache invalidation.
4.3 Responses
PageSummaryandPageResponse(app/schemas.py) gaintags: list[str](canonical names, position order)._page_summaryinapp/services/pages.pypopulates it from the relationship.list_pagesmust avoid N+1: eager-load (selectinload) or one grouped query for the page IDs on the current page. KeepPage.idas secondary sort key (do-not-regress #9).- The search summary builder
_summary_from_row(app/services/search.py) works from a columnRow, not an ORM object — fetch tags for the winning top-N page IDs in one additional query after ranking (and infetch_active_summaries_by_idsfor cache-hit hydration).
4.4 New: GET /v1/tags
New router app/api/v1/tags.py (mounted in app/main.py beside /v1/pages), service logic in a new app/services/tags.py.
GET /v1/tags?limit=100&offset=0 (limit 1..500, default 100)
→ 200 {"items": [{"name": "agent-pages", "page_count": 12}, ...],
"limit": 100, "offset": 0, "total": 37}
- Auth:
require_publish_auth(same as other/v1routes; wrong token 401, unset 503). page_countcounts active pages only (page_is_active_clause(now)+purge_started_at IS NULL), joined throughpage_tags. Tags with zero active pages are omitted.- Order:
page_count desc, name asc. - Handler is sync
def(do-not-regress #22).
5. Search changes
5.1 Query surface
GET /search accepts, in addition to q:
- Repeated
tagparams:/search?tag=agent-pages&tag=research(optionally withq=...). - Validation, in this order, all before consuming any rate budget (mirrors the over-64
qrule, do-not-regress #26):- more than 10
tagparams → 400"Use at most 10 tag filters." - any param failing T1 grammar (after normalization) → 400 naming the bad value (sanitized via
sanitize_display_textbefore reflection). - duplicate tags after normalization → deduplicate silently (not an error — trivially reachable via crafted URLs).
- more than 10
- Valid-but-unknown tags are allowed and simply match nothing (they are filters).
- Blank/missing
qwith valid tags does not redirect home — it runs a tags-only search. Blankqwith no tags keeps today's 303 →/. qalone: behavior byte-for-byte identical to today.
Add the validation helper in app/search_query.py (e.g. validate_search_tags(raw_values) -> TagsValidation) so viewer code stays thin; reuse the T1 regex from one shared constant (suggest app/services/tags.py exporting TAG_PATTERN, imported by schemas and search_query — mind the import direction: app/ modules may import from app/services/, mcp_server/ may not).
5.2 Execution paths (app/services/search.py, app/search_cache.py)
Three shapes:
qonly — unchanged code path.- tags only — new cheap path: one SQL query joining
page_tags/tagswith the active/purge predicates,GROUP BY page_idwithCOUNT(*) AS tag_match_count, ordered(tag_match_count desc, updated_at desc, id desc), limitsearch_result_limit(50). No RapidFuzz, no scoring semaphore — do not acquire a search slot. Consumes normal search rate budget. Not LRU-cached in v1 (it is one indexed query; caching adds key cardinality for no measured win — note this in code). q+ tags —fetch_active_search_candidatesgains an optional tag filter: candidates are restricted (SQL join as above) to pages matching ≥1 selected tag, and each candidate carries itstag_match_count. Scoring/admission (D62–D64 passes) is unchanged and runs on the filtered candidate list. Final ranking:(score desc, tag_match_count desc, updated_at desc, id desc)— extend_rank_matches.valid_untilderives from the filtered candidate snapshot (same mechanism, do-not-regress #24).
5.3 Cache key
SearchCacheKey (app/search_cache.py) extends to include the sorted tuple of canonical tags (empty tuple when none): (normalized_query, tags_tuple, generation, cutoff, limit). All lookup/store/invalidate helpers and get_or_compute_search take the tag tuple. Generation semantics unchanged: tag edits only happen inside create/update, which already bump — cached tag-filtered results invalidate correctly with zero new machinery (do-not-regress #23). Consider raising search_cache_max_entries default 256 → 512 to absorb the added key cardinality (config change in app/config.py; optional, cheap: entries are small per the 2.9 MiB benchmark note in MEMORY.md).
5.4 Rate limits
Unchanged budgets and atomicity (do-not-regress #26): every accepted /search request — q-only, tags-only, or both, cache hit or miss — consumes burst + sustained in the one existing atomic transaction. Validation failures (over-64 q, >10 tags, bad tag grammar) reject before any budget.
6. Viewer changes
6.1 Ledger pills (/, /page/{n}, /search results)
app/viewer/rows.pyrow()addstags: list[str]fromPageSummary.tags.app/templates/viewer/_ledger.html: render pills inside the existing Page cell (ledger-cell-page), on a line under the title/slug. Do not add an eighth column — the sub-48rem mobile collapse depends on explicitgrid-rowper cell and the table is already dense. Keep explicit ARIA roles intact.- Each pill is a link to
/search?tag={name}(URL-encode defensively even though the grammar is URL-safe). - Styling in
app/static/viewer-home.css: muted mono chips (border or faint background,--muted-family ink), wrapping allowed. Format keeps the page's only color (D43) — pills stay ink/muted, no new hues. Verify the 390px mobile collapse still lays out correctly with 5 pills (the known-fragile case fromMEMORY.md2026-07-31).
6.2 Search form and selected-tag pills
app/templates/viewer/_search_form.html: the form (used on/,/page/{n},/search) gains a tag text input (name="tag",maxlength="32") withlist="tag-options", plus a<datalist id="tag-options">populated server-side with the active vocabulary (cap at top 100 by page_count; provided via template context fromapp/services/tags.py). When rendering on/searchwith active filters, emit<input type="hidden" name="tag" value="...">per selected tag and a hiddenqcarrying the current query, so submitting adds the typed tag to the existing state. Ledger pages (/,/page/{n}) render the same form without hidden state.app/templates/viewer/search.html: above results, render each selected tag as a pill with a remove link — an<a>to/searchre-serialized with that tag omitted (keepqand other tags). Also show a "clear all tags" link when ≥2 tags. Result messaging: extend_search_caption/result_messageinapp/viewer/search.pyto name the filters, e.g.3 matches for “plan” tagged agent-pages or research.app/viewer/search.py: parse+validatetagparams (Section 5.1), thread the tag tuple throughget_or_compute_search/ the tags-only path, build pill/remove-link hrefs server-side (single helper that serializesq+ tags to a canonical/searchURL; write it once, use it for pills, remove links, and the form).app/viewer/index.py: pass the datalist vocabulary into the home/page-n context.
6.3 Theme round-trip
/viewer/theme/{theme} currently re-synthesizes only q for next=/search (set_viewer_theme in app/viewer/routes.py, theme_set_href/parse_theme_search_query in app/viewer/theme.py). Extend both ends so validated tag params round-trip too (revalidate against T1, cap 10, drop invalid silently). validate_next_path itself must not change — it stays path-only and strict (do-not-regress: D40/D60); tags ride as extra query params on the theme URL exactly like q does.
6.4 CSP / headers
No changes. /search, /, /page/{n} already send form-action 'self' (do-not-regress #27); <datalist> needs no script. All new handlers stay sync def (do-not-regress #22).
7. MCP changes (mcp_server/)
Thin HTTP client only — no storage/model imports (do-not-regress #1, #20).
mcp_server/client.py:publish_page(..., tags=None),update_page(..., tags=None)forwardtagswhen not None; newlist_tags(limit=100, offset=0)→GET /v1/tags.mcp_server/server.py:publish_pagetool gainstags: list[str] | None = None. Validate client-side before dispatch (T1 grammar, ≤5, no dupes) so agents get fast errors. Tool description gains: "Optionally attach up to 5 single-word tags (1-32 chars, lowercase [a-z0-9._-]). Call list_tags first and prefer existing tags; create new ones only when none fit. Convention: the first tag is the repo or top-level folder name the artifact was produced from. Tags are public metadata on the ledger, even for PIN-locked pages."update_pagetool gainstags: list[str] | None = None— omitted preserves, present replaces (mirror the API).- New read-only tool
list_tags(limit: int = 100, offset: int = 0)returning the vocabulary with counts (structured content:{items: [{name, page_count}], total, limit, offset}). - Version-skew (T9): after a publish/update that sent tags, if the response payload lacks a matching
tagsfield, append a warning line to the success text (e.g."Note: the server did not confirm tags; it may predate tag support.") — do not raise. page_refpassestagsthrough when present so structured results include it.
8. File-by-file change list
| File | Change |
|---|---|
app/models.py |
Add Tag, PageTag; Page.tags relationship ordered by position. |
app/alembic/versions/0003_page_tags.py |
New migration (up + down). |
app/schemas.py |
Shared tag validator; tags on PageCreateRequest / PageUpdateRequest (optional) / PageSummary / PageResponse; TagCount + TagListResponse. |
app/services/tags.py (new) |
TAG_PATTERN, normalize_tag, upsert helper used by pages service, list_tag_counts (active-only), datalist vocabulary helper. |
app/services/pages.py |
Create/update tag persistence in-transaction; _page_summary tags; list_pages eager loading. |
app/api/v1/tags.py (new) + app/main.py |
GET /v1/tags router, mounted. |
app/api/v1/pages.py |
No structural change (schemas carry the field); verify error mapping for tag validation is 422 via pydantic. |
app/services/search.py |
Tag-filtered candidate fetch with tag_match_count; tags-only listing path; ranking extension; top-N tag hydration; fetch_active_summaries_by_ids tags. |
app/search_cache.py |
Cache key gains sorted tag tuple; thread through all helpers. |
app/search_query.py |
validate_search_tags. |
app/search_limits.py |
No change (verify only). |
app/viewer/search.py |
Parse tags, three-shape dispatch, pill/remove-URL builder, captions/messages. |
app/viewer/index.py |
Datalist vocabulary in context. |
app/viewer/rows.py |
tags in row dict. |
app/viewer/theme.py, app/viewer/routes.py |
Theme round-trip carries validated tags for next=/search. |
app/templates/viewer/_ledger.html |
Pills in Page cell. |
app/templates/viewer/_search_form.html |
Tag input + <datalist> + hidden state. |
app/templates/viewer/search.html |
Selected-tag pills with remove links. |
app/static/viewer-home.css |
Pill styles, mobile collapse verification. |
app/config.py |
Optional: search_cache_max_entries 256 → 512. |
mcp_server/client.py, mcp_server/server.py |
Tags params, list_tags tool, skew warning. |
tests/ |
See Section 9. |
AGENTS.md, MEMORY.md |
New decisions T1–T10 as D65+; update locked-decision table, layout, API table, viewer/search sections, do-not-regress additions. |
Out of scope (do not build): tags-only PATCH endpoint, tag rename/merge/delete admin, fuzzy tag matching, tags in the RapidFuzz corpus, per-version tags, JS-driven pill editing, FTS5 work, /v1/tags public (unauthenticated) exposure.
9. Testing and success criteria
The change is done when all of the following hold. Run pip install -e ".[dev]" then python -m pytest — the full suite must pass, including every existing test (regressions in untouched tests mean the change is wrong, not the test).
New/extended automated tests
tests/test_tags_validation.py(new): T1 grammar matrix — acceptsa,agent-pages,next.js,my_notes, 32-char tag; rejects empty, 33 chars, spaces, leading-/_/., non-ASCII (e.g.café, fullwidth forms after NFKC), control chars; casefold applies (Agent-Pages→agent-pagesaccepted); >5 tags; duplicates post-normalization (Foo,foo) → 422.tests/test_api_tags.py(new): POST with tags persists order and canonical names; PUT omitted preserves; PUT[]clears; PUT replace works; responses (POST/PUT/GET one/GET list) carrytags;GET /v1/tagscounts active pages only (publish two pages sharing a tag, expire one via fixture, count drops), ordering, pagination, 401/503 auth behavior; concurrent-ish duplicate tag creation does not 500 (upsert race guard).tests/test_search_tags.py(new): tags-only search returns match-count-then-recency ordering; OR semantics (page with either tag matches; match-count ranks 2-tag match above 1-tag);q+tags filters candidates before scoring (a fuzzy-matching title without the tag is excluded); >10 tags → 400 with no rate budget consumed (assert via limiter state, mirroring existing over-64 tests intests/test_search_rate_limit.py); invalid tag → 400 sanitized reflection; unknown valid tag → zero results, no error; blankq+ tags searches (no 303); blankqno tags still 303s.tests/test_search_cache.py(extend): cache key separates sameqwith different tag sets; tag-filtered entries invalidate on create/update generation bump; tags-only path does not acquire a scoring slot (assert semaphore untouched) and is not cached.tests/test_home_index.py/tests/test_search_viewer.py(extend): pills render on ledger and search rows with correct hrefs; locked pages show pills; selected-tag pills with correct remove links (removing one keepsq+ others); datalist present with vocabulary; hidden inputs carry state so form submits add tags.tests/test_viewer_theme.py(extend): theme link on/search?q=x&tag=a&tag=bround-trips both; invalid tags dropped;validate_next_pathstill rejects query strings (unchanged assertions must stay green).tests/test_mcp_server.py(extend):publish_page/update_pageforward tags; client-side validation errors;list_tagstool result shape; skew warning appended when response lacks tags (fake client returning legacy payload); structuredpage_refincludes tags.- Migration (
tests/test_search_migration.pypattern):agent-pages init-dbon a fresh DB creates both tables; upgrade on a pre-0003 volume works;alembic downgrade -1drops them cleanly. - Purge (
tests/test_purge_expired.pyextend): purging a tagged page removes itspage_tagsrows, leavestagsrows, and the vocabulary no longer lists a tag whose only page was purged.
Manual verification (required, with artifacts)
Run the app locally (agent-pages init-db, uvicorn app.main:app --port 8000 with fake/dev R2 or the test harness), publish 3–4 pages with overlapping tags via curl or MCP, then verify in a real browser: pills on /; click a pill → filtered /search; add a second tag via the form (datalist suggestions appear); remove a pill via its link; combine with a fuzzy q; switch theme mid-search and confirm q + tags survive; check the 390px mobile layout with a 5-tag row. Capture screenshots/video per the walkthrough-artifact rules.
Performance guardrail
Run python scripts/benchmark_search.py --pages 1000 before and after; the q-only miss path must not regress materially (>10%). Tag filtering is expected to be neutral-to-faster (smaller candidate sets). The standing FTS5 escalation disposition in MEMORY.md is unchanged by this feature — do not start FTS5 work.
10. Hard constraints (do not regress)
Copy of the rules most likely to be violated by this work — the full list in AGENTS.md still applies:
- Route handlers stay sync
def(AGENTS.md #22). - Corpus generation bumps only inside successful create/update transactions; purge never bumps (#23). Tag mutations must not add a second bump path.
- Search rate budget: burst + sustained in one atomic transaction; validation failures reject before budget; cache hits still count (#26).
validate_next_pathstays path-only — never teach it query strings (D40/D60).- No JavaScript on
/,/page/{n},/search; no CSP loosening anywhere; artifacts//rawuntouched. - Search scores titles only — tags filter, never enter the fuzzy corpus (#25).
- MCP stays HTTP-only, no
app/services/orapp/modelsimports (#1). - Schema changes via Alembic with working one-revision downgrade (#6).
Page.idstays a secondary sort key everywhere pagination exists (#9).- PIN/expiry semantics untouched; tags never gate reads or authorize writes.
11. Delivery checklist
- Implement per Sections 3–8; commit in logical units.
- Full
python -m pytestgreen; new tests from Section 9 present and green. - Manual browser verification done with artifacts.
AGENTS.md+MEMORY.mdupdated (new decisions, layout/API/viewer sections, do-not-regress additions for tags).- PR opened with the evidence, marked ready for review when complete (MEMORY.md rule 7 / D35).