agent-pages

Page Tags (v1) — Implementation Plan

Download

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:

  1. Ledger pills — tags render as small pills on each row of the public ledger (/ and /page/{n}) and on /search results. Every tag of a page is shown.
  2. Tag search/search accepts 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 lowercaseAgent-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

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}

5. Search changes

5.1 Query surface

GET /search accepts, in addition to q:

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:

  1. q only — unchanged code path.
  2. tags only — new cheap path: one SQL query joining page_tags/tags with the active/purge predicates, GROUP BY page_id with COUNT(*) AS tag_match_count, ordered (tag_match_count desc, updated_at desc, id desc), limit search_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).
  3. q + tagsfetch_active_search_candidates gains an optional tag filter: candidates are restricted (SQL join as above) to pages matching ≥1 selected tag, and each candidate carries its tag_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_until derives 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)

6.2 Search form and selected-tag pills

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).

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

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:

  1. Route handlers stay sync def (AGENTS.md #22).
  2. Corpus generation bumps only inside successful create/update transactions; purge never bumps (#23). Tag mutations must not add a second bump path.
  3. Search rate budget: burst + sustained in one atomic transaction; validation failures reject before budget; cache hits still count (#26).
  4. validate_next_path stays path-only — never teach it query strings (D40/D60).
  5. No JavaScript on /, /page/{n}, /search; no CSP loosening anywhere; artifacts//raw untouched.
  6. Search scores titles only — tags filter, never enter the fuzzy corpus (#25).
  7. MCP stays HTTP-only, no app/services/ or app/models imports (#1).
  8. Schema changes via Alembic with working one-revision downgrade (#6).
  9. Page.id stays a secondary sort key everywhere pagination exists (#9).
  10. PIN/expiry semantics untouched; tags never gate reads or authorize writes.

11. Delivery checklist

  1. Implement per Sections 3–8; commit in logical units.
  2. Full python -m pytest green; new tests from Section 9 present and green.
  3. Manual browser verification done with artifacts.
  4. AGENTS.md + MEMORY.md updated (new decisions, layout/API/viewer sections, do-not-regress additions for tags).
  5. PR opened with the evidence, marked ready for review when complete (MEMORY.md rule 7 / D35).