# SLICE-3 — Faceted left-pane filtering (read) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give a collection that declares a `fields:` schema a faceted left pane — the list endpoint returns per-value facet counts and honours filter params (`?priority=P0&tags=checkout&state=active&malformed=true`; OR within a field, AND across fields), and the catalog renders collapsible facet groups with counts, multi-select checkboxes, and a tag-value search box. A collection with no schema behaves exactly as today (INV-5 → the N=1 `document` deployment sees zero change).

**Architecture:** Per-entry metadata values aren't currently cached (`Entry.extra` is dropped on ingest), so facets can't be computed. A new additive column `cached_rfcs.meta_json` (migration 034) persists the full metadata mapping (`metadata.metadata_dict(entry)`) at ingest. A new pure module `app/facets.py` computes the facetable field set (declared `enum`+`tags` fields, in schema order, plus the built-in `state` facet) and does the filter + drill-down counting over the mirrored rows (small set, hundreds–~1.2k; in-Python is fine). The collection-scoped list endpoint (`_list_rfcs_for_collection`, shared by the project- and collection-scoped routes) reads filter params from the query string, 400s on an unknown field, and returns `{items (each + its `meta`), facets}`. The frontend `Catalog.jsx` renders a faceted pane (`FacetGroups.jsx`) when `collection.fields` is present — re-fetching server-side on each selection change — and otherwise keeps the legacy state chips untouched. Search box + sort remain client-side in both modes.

**Tech Stack:** FastAPI + SQLite (raw SQL migrations run by `backend/app/db.py:run_migrations`, glob-ordered); pytest vertical/unit tests (`backend/tests/`, reusing the fake-Gitea `app_with_fake_gitea` fixture + `_set_default_fields_schema` helper from `test_metadata_cache.py`); React Router SPA (`frontend/src/`), Vitest + Testing Library component tests mocking `../api`.

**Binding spec:** `docs/design/2026-06-06-configurable-collection-metadata.md` — §5.1 (catalog left pane), §6.4 (interfaces), §6.2/§6.3 (architecture/data model), §7.2 SLICE-3 DoD, PUC-3, INV-3/INV-5/INV-7.

---

## Decisions locked before coding (read first)

1. **Facetable fields = declared `enum` + `tags` fields (in schema declaration order) + the built-in `state` facet appended last.** `text`-type fields are **not** faceted in v1 (free text makes a poor facet group; they get a detail control in SLICE-4). The built-in `state` facet is added **only when the collection declares a schema** — so INV-5 holds: a no-`fields:` collection returns *no* `facets` at all and the frontend keeps its legacy state chips. `state` here is the entry's lifecycle state column (`super-draft`/`active`); the base query already restricts to those two, so `withdrawn`/`retired` never appear as facet values.

2. **Facet counts use drill-down semantics:** the count for a value of field *F* is computed over the entries matching every *other* field's active selection (and the `malformed` toggle), but **not** *F*'s own selection. This keeps within-field values switchable (you can move from P0 to P1 without first clearing P0) and matches "OR within a field, AND across fields". The returned `items` list is filtered by **all** selections (every field AND-combined, malformed toggle applied).

3. **`malformed` is a cross-cutting toggle, not its own facet group.** `?malformed=true` narrows both `items` and every facet's counts to entries whose `metadata_malformed` flag is set. Malformed entries are otherwise **never hidden** from the normal list (INV-3); each item already carries `metadata_malformed` so the row can show a fixable marker.

4. **Facets live on the collection-scoped path only** (`_list_rfcs_for_collection`, used by `GET /api/projects/{pid}/rfcs` for the default collection and `GET /api/projects/{pid}/collections/{cid}/rfcs`). The unscoped cross-collection `GET /api/rfcs` is left untouched — it spans collections with potentially different schemas, so a single facet set is meaningless there. This bounds the blast radius and keeps the legacy catalog path identical.

5. **`meta_json` stores the full `metadata.metadata_dict(entry)`** (known keys + `extra`, never the body) as JSON, not just `extra`. Uniform per-field lookup (`meta.get(name)`) serves `enum`/`tags`/custom fields and is directly reusable by SLICE-4/5 edit panels. Additive nullable column; existing rows read `NULL` (→ empty facets) until the reconciler/webhook re-ingests them — no rebuild required, and the reconciler sweep runs on startup.

6. **Allowed query-param keys** for the collection-scoped list = the facet field names ∪ `{"unreviewed", "malformed"}`. `unreviewed` keeps its existing meaning (narrow to unreviewed active entries) and stays a typed param. Any other key → **HTTP 400** "unknown filter field". An empty-valued selection (`?priority=`) is ignored, not 400.

---

## File structure

**Backend**
- Create `backend/migrations/034_cached_rfc_meta.sql` — add `meta_json TEXT` to `cached_rfcs`.
- Create `backend/app/facets.py` — pure facet field set + filter/count helpers.
- Modify `backend/app/cache.py` — `_upsert_cached_rfc` persists `meta_json`.
- Modify `backend/app/api.py` — `_list_rfcs_for_collection` reads filters, returns `{items(+meta), facets}`, 400 on unknown field; the two collection-scoped callers pass the query params.
- Create `backend/tests/test_facets.py` — unit tests for `app/facets.py`.
- Create `backend/tests/test_facets_api.py` — integration tests through `TestClient`.

**Frontend**
- Modify `frontend/src/api.js` — `listRFCs` accepts filter selections + returns the full `{items, facets}` payload.
- Create `frontend/src/components/FacetGroups.jsx` — the faceted-pane subcomponent.
- Modify `frontend/src/components/Catalog.jsx` — faceted mode (gated on `fields`) vs legacy chips; server-side re-fetch on selection change.
- Modify `frontend/src/App.css` (or the catalog stylesheet) — facet group styles.
- Create `frontend/src/components/Catalog.test.jsx` — Vitest component test.

**Docs / release**
- Modify `CHANGELOG.md`, `VERSION`, `frontend/package.json` — v0.49.0.

---

## Task 1: Migration 034 — persist per-entry metadata values

**Files:**
- Create: `backend/migrations/034_cached_rfc_meta.sql`
- Test: `backend/tests/test_facets_api.py` (the persistence assertion lands in Task 6; this task only proves the migration applies)

- [ ] **Step 1: Write the migration**

```sql
-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values.
--
-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom
-- enum/tags fields) to compute facet counts and honour filter params. Today the
-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`,
-- so a declared field's values are unrecoverable. This column persists the full
-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys +
-- extra, never the body) as JSON, so `app/facets.py` can read any declared
-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested
-- (the reconciler/webhook fills it on the next sweep) → that entry contributes
-- no facet values until then. SLICE-4/5 edit panels read the same column.
ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT;
```

- [ ] **Step 2: Run the backend suite to confirm the migration applies cleanly**

Run: `cd backend && python -m pytest tests/test_metadata_cache.py -q`
Expected: PASS (the migration runner applies 034 with no error; existing tests unaffected — `meta_json` is nullable and unread so far).

- [ ] **Step 3: Commit**

```bash
git add backend/migrations/034_cached_rfc_meta.sql
git commit -m "feat(slice3): migration 034 — cached_rfcs.meta_json for facet values (§22.4a)"
```

---

## Task 2: Persist `meta_json` at ingest

**Files:**
- Modify: `backend/app/cache.py` (`_upsert_cached_rfc`, lines ~161-228)
- Test: `backend/tests/test_facets_api.py::test_meta_json_persisted_at_ingest` (written in Task 6)

- [ ] **Step 1: Add the `meta_json` value in `_upsert_cached_rfc`**

In `backend/app/cache.py`, locate the top of `_upsert_cached_rfc` (just after the `funder_login` line) and add:

```python
    # §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys +
    # extra, never the body) so facet/filter can read any declared field. Stored
    # via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along.
    meta_json = json.dumps(metadata_mod.metadata_dict(entry))
```

Confirm `cache.py` already imports the metadata module. It imports it as `metadata_mod` (used in `refresh_meta_repo`). If the import is local to that function, add a module-level `from . import metadata as metadata_mod` alongside the other imports at the top of `cache.py`.

- [ ] **Step 2: Add the column to the INSERT column list and VALUES**

In the `INSERT INTO cached_rfcs (...)` column list, add `meta_json` after `metadata_malformed`:

```python
             unreviewed, reviewed_at, reviewed_by, collection_id,
             metadata_malformed, meta_json, last_entry_commit_at, updated_at)
```

Add one more `?` to the `VALUES (...)` row (there is one placeholder per column before the two `datetime('now')` literals).

- [ ] **Step 3: Add `meta_json` to the ON CONFLICT update and the params tuple**

In the `ON CONFLICT(collection_id, slug) DO UPDATE SET` clause, add after the `metadata_malformed = excluded.metadata_malformed,` line:

```python
            meta_json = excluded.meta_json,
```

In the params tuple, add `meta_json,` immediately after the `1 if metadata_malformed else 0,` entry (matching the new column position).

- [ ] **Step 4: Run the cache tests**

Run: `cd backend && python -m pytest tests/test_metadata_cache.py -q`
Expected: PASS (no behaviour change yet — the column is now populated but unread).

- [ ] **Step 5: Commit**

```bash
git add backend/app/cache.py
git commit -m "feat(slice3): persist entry metadata to cached_rfcs.meta_json at ingest (§22.4a)"
```

---

## Task 3: Pure facet module — field set + filter/count

**Files:**
- Create: `backend/app/facets.py`
- Test: `backend/tests/test_facets.py`

- [ ] **Step 1: Write the failing unit tests**

Create `backend/tests/test_facets.py`:

```python
"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3).

Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4.
"""
from app import facets


PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}}
SCHEMA = {
    "priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
    "tags": {"type": "tags"},
    "owner": {"type": "text"},
}


def _e(slug, state="active", malformed=False, **meta):
    return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta}


def test_facet_fields_orders_declared_then_state_skips_text():
    # enum + tags in declaration order, text skipped, state appended last.
    assert facets.facet_fields(SCHEMA) == [
        ("priority", "enum"), ("tags", "tags"), ("state", "enum")]


def test_no_schema_yields_no_facets():
    # INV-5: a collection with no fields has no facets (frontend keeps chips).
    assert facets.facet_fields(None) == []
    assert facets.facet_fields({}) == []


def test_filter_and_count_basic_counts():
    entries = [
        _e("a", priority="P0", tags=["checkout"]),
        _e("b", priority="P0", tags=["cart"]),
        _e("c", priority="P1", tags=["checkout", "cart"]),
    ]
    items, fac = facets.filter_and_count(entries, SCHEMA, {})
    assert {i["slug"] for i in items} == {"a", "b", "c"}
    assert fac["priority"] == {"P0": 2, "P1": 1}
    assert fac["tags"] == {"checkout": 2, "cart": 2}
    assert fac["state"] == {"active": 3}


def test_filter_compose_or_within_and_across():
    entries = [
        _e("a", priority="P0", tags=["checkout"]),   # P0 + checkout
        _e("b", priority="P0", tags=["cart"]),        # P0, no checkout
        _e("c", priority="P1", tags=["checkout"]),    # checkout, not P0
    ]
    # priority=P0 AND tags=checkout → only "a".
    items, _ = facets.filter_and_count(
        entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}})
    assert {i["slug"] for i in items} == {"a"}


def test_drilldown_counts_exclude_own_field_selection():
    entries = [
        _e("a", priority="P0"),
        _e("b", priority="P1"),
        _e("c", priority="P1"),
    ]
    # With P0 selected, the priority facet still counts P1 over the set that
    # ignores priority's own selection — so P1 stays switchable.
    _, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}})
    assert fac["priority"] == {"P0": 1, "P1": 2}


def test_malformed_toggle_narrows_items_and_counts():
    entries = [
        _e("a", state="active", malformed=True, priority="P9"),
        _e("b", state="active", malformed=False, priority="P0"),
    ]
    items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True)
    assert {i["slug"] for i in items} == {"a"}
    assert fac["priority"] == {"P9": 1}


def test_missing_value_contributes_no_facet_value():
    entries = [_e("a", priority="P0"), _e("b")]  # b has no priority
    _, fac = facets.filter_and_count(entries, SCHEMA, {})
    assert fac["priority"] == {"P0": 1}


def test_allowed_filter_keys():
    assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state",
                                                    "unreviewed", "malformed"}
```

- [ ] **Step 2: Run to verify failure**

Run: `cd backend && python -m pytest tests/test_facets.py -q`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.facets'`.

- [ ] **Step 3: Implement `app/facets.py`**

```python
"""§22.4a SLICE-3 — faceted catalog filtering + counts (read).

Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is
a plain dict carrying at least:
  - "state": the lifecycle state column,
  - "metadata_malformed": bool,
  - "meta": the per-entry metadata mapping (from cached_rfcs.meta_json).

Facetable fields (§5.1, decision 1): a collection's declared `enum` and `tags`
fields, in declaration order, plus the built-in `state` facet appended last —
but only when the collection declares a schema (INV-5: a no-`fields:` collection
has no facets at all, so the frontend keeps its legacy chips). `text` fields are
not faceted in v1.

Counts use drill-down semantics (decision 2): the count for a value of field F
is taken over entries matching every OTHER field's selection (and the malformed
toggle), not F's own — so within-field values stay switchable (OR within a
field, AND across fields). The returned items list applies ALL selections.
"""
from __future__ import annotations

from typing import Any

# enum + tags are facetable; text is rendered as a detail control (SLICE-4).
FACETABLE_TYPES = {"enum", "tags"}


def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]:
    """Ordered `[(name, type), ...]` facetable from the schema, `state` last.

    Empty when the collection declares no schema (INV-5)."""
    if not fields:
        return []
    out = [
        (name, spec.get("type"))
        for name, spec in fields.items()
        if spec.get("type") in FACETABLE_TYPES
    ]
    out.append(("state", "enum"))
    return out


def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]:
    """Query-param keys the collection-scoped list accepts (decision 6)."""
    keys = {name for name, _ in facet_fields(fields)}
    keys.update({"unreviewed", "malformed"})
    return keys


def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]:
    """The facet value(s) an entry contributes for field `name` (str-cast)."""
    if name == "state":
        v = entry.get("state")
        return [str(v)] if v else []
    meta = entry.get("meta") or {}
    v = meta.get(name)
    if v is None:
        return []
    if ftype == "tags":
        return [str(x) for x in v] if isinstance(v, list) else []
    return [str(v)]


def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool:
    if not selected:
        return True
    return bool(set(_values_for(entry, name, ftype)) & selected)  # OR within field


def filter_and_count(
    entries: list[dict[str, Any]],
    fields: dict[str, dict] | None,
    selections: dict[str, set[str]],
    only_malformed: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]:
    """Filter `entries` by `selections` and compute drill-down facet counts.

    `selections` maps a facet field name → the set of selected values (OR within
    the field; AND across fields). `only_malformed` narrows items and counts to
    entries flagged malformed (INV-3). Returns `(items, facets)` where
    `facets = {field: {value: count}}`. With no schema → `([all passing], {})`.
    """
    facetable = facet_fields(fields)

    def passes_malformed(e: dict[str, Any]) -> bool:
        return (not only_malformed) or bool(e.get("metadata_malformed"))

    items = [
        e for e in entries
        if passes_malformed(e)
        and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable)
    ]

    facets: dict[str, dict[str, int]] = {}
    for name, ftype in facetable:
        counts: dict[str, int] = {}
        for e in entries:
            if not passes_malformed(e):
                continue
            if not all(
                _matches(e, on, ot, selections.get(on, set()))
                for on, ot in facetable
                if on != name
            ):
                continue
            for val in _values_for(e, name, ftype):
                counts[val] = counts.get(val, 0) + 1
        facets[name] = counts
    return items, facets
```

- [ ] **Step 4: Run to verify pass**

Run: `cd backend && python -m pytest tests/test_facets.py -q`
Expected: PASS (8 tests).

- [ ] **Step 5: Commit**

```bash
git add backend/app/facets.py backend/tests/test_facets.py
git commit -m "feat(slice3): pure facet field-set + filter/count helper (§22.4a)"
```

---

## Task 4: Wire facets into the collection-scoped list endpoint

**Files:**
- Modify: `backend/app/api.py` (`_list_rfcs_for_collection` at ~739-784; callers `list_project_rfcs` ~807, `list_collection_rfcs` ~828)
- Test: `backend/tests/test_facets_api.py` (Task 6)

- [ ] **Step 1: Confirm imports**

At the top of `api.py`, confirm `facets` and `collections_mod` are importable. `collections_mod` is already imported (used in `_require_collection_in_project`). Add `from . import facets` to the module imports if absent.

- [ ] **Step 2: Rewrite `_list_rfcs_for_collection` to filter + facet**

Replace the body of `_list_rfcs_for_collection` with a version that takes the raw query params, reads `meta_json`, validates filter keys, and returns `{items, facets}`:

```python
    def _list_rfcs_for_collection(
        collection_id: str, viewer, unreviewed: str | None,
        query_params=None,
    ) -> dict[str, Any]:
        viewer_id = viewer.user_id if viewer else None
        # §22.4a SLICE-3: the collection's declared field schema drives the facet
        # set (None when undeclared → no facets, INV-5).
        col = collections_mod.get_collection(collection_id)
        fields_schema = (col or {}).get("fields") or None

        # Parse + validate filter selections from the query string. Unknown
        # field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
        # empty-valued selection is ignored, not an error (decision 6).
        selections: dict[str, set[str]] = {}
        only_malformed = False
        if query_params is not None:
            allowed = facets.allowed_filter_keys(fields_schema)
            facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
            for key in query_params.keys():
                if key not in allowed:
                    raise HTTPException(400, f"unknown filter field {key!r}")
            if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
                only_malformed = True
            for name in facet_names:
                vals = {v for v in query_params.getlist(name) if v != ""}
                if vals:
                    selections[name] = vals

        unreviewed_clause = ""
        if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
            unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
        rows = db.conn().execute(
            f"""
            SELECT slug, title, state, rfc_id, repo,
                   owners_json, arbiters_json, tags_json, metadata_malformed,
                   meta_json,
                   last_main_commit_at, last_entry_commit_at, updated_at
              FROM cached_rfcs
             WHERE state IN ('super-draft', 'active')
               AND collection_id = ?{unreviewed_clause}
             ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
            """,
            (collection_id,),
        ).fetchall()
        starred = set()
        if viewer_id is not None:
            starred = {
                r["rfc_slug"]
                for r in db.conn().execute(
                    "SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
                    (viewer_id, collection_id),
                )
            }

        # Build entry dicts the facet helper understands (state + malformed +
        # parsed meta), preserving SQL order.
        entries = []
        for r in rows:
            try:
                meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
            except (TypeError, ValueError):
                meta = {}
            entries.append({
                "slug": r["slug"],
                "title": r["title"],
                "state": r["state"],
                "id": r["rfc_id"],
                "repo": r["repo"],
                "owners": json.loads(r["owners_json"] or "[]"),
                "arbiters": json.loads(r["arbiters_json"] or "[]"),
                "tags": json.loads(r["tags_json"] or "[]"),
                "last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
                "starred_by_me": r["slug"] in starred,
                "has_open_prs": False,
                "metadata_malformed": bool(r["metadata_malformed"]),
                "meta": meta,
            })

        filtered, facet_counts = facets.filter_and_count(
            entries, fields_schema, selections, only_malformed=only_malformed
        )
        return {"items": filtered, "facets": facet_counts}
```

Note: `metadata_malformed` is a Python `bool` here (the facet helper reads it as the per-entry flag); the response keeps the same `metadata_malformed` key the frontend already reads. `meta` is additive.

- [ ] **Step 3: Pass query params from the two collection-scoped callers**

In `list_project_rfcs`, change the final return to forward the request's query params:

```python
        return _list_rfcs_for_collection(
            collection_id, viewer, unreviewed, query_params=request.query_params
        )
```

In `list_collection_rfcs`, likewise:

```python
        return _list_rfcs_for_collection(
            collection_id, viewer, unreviewed, query_params=request.query_params
        )
```

Leave the unscoped `/api/rfcs` (`list_rfcs`) untouched (decision 4).

- [ ] **Step 4: Run the existing collection-serve + cache tests**

Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py tests/test_metadata_cache.py tests/test_s1_collection_grain_vertical.py -q`
Expected: PASS. The response now also carries `facets` (additive) and each item a `meta` key; existing assertions read `items[...]` fields and are unaffected. If any test asserts the exact item-key set, update it to allow the additive `meta` key.

- [ ] **Step 5: Commit**

```bash
git add backend/app/api.py
git commit -m "feat(slice3): collection list endpoint returns facets + honours filters (§22.4a)"
```

---

## Task 5: Backend integration tests for facets + filtering through the API

**Files:**
- Create: `backend/tests/test_facets_api.py`

- [ ] **Step 1: Write the integration tests**

Create `backend/tests/test_facets_api.py`, reusing the fake-Gitea fixture and the schema helper pattern from `test_metadata_cache.py`. The default project's default collection serves at `/api/projects/<pid>/rfcs`; resolve the project id from the deployment config (`load_config().default_project_id` — confirm the attribute name from `app/config.py`; the existing vertical tests read the same value).

```python
"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4).

Through the real API: schema-declared facets, filter params (OR within / AND
across), drill-down counts, malformed toggle, unknown-field 400, and meta_json
persistence. Reuses the fake-Gitea harness from test_metadata_cache.
"""
from __future__ import annotations

import asyncio
import json

from fastapi.testclient import TestClient

from app import cache, db, gitea as gitea_mod
from app.config import load_config

from test_propose_vertical import (  # noqa: F401 (fixtures)
    app_with_fake_gitea,
    tmp_env,
)


def _refresh():
    cfg = load_config()
    asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))


def _set_default_fields_schema(schema):
    db.conn().execute(
        "UPDATE collections SET config_json = ? WHERE id = 'default'",
        (json.dumps({"fields": schema}),))


def _pid():
    # The default project id the deployment is stamped with.
    return load_config().default_project_id


def _seed(fake, slug, *, state="active", **front):
    fm = {"slug": slug, "title": slug.title(), "state": state, **front}
    lines = "\n".join(
        f"{k}: {v}" if not isinstance(v, list)
        else f"{k}:\n" + "".join(f"- {m}\n" for m in v).rstrip()
        for k, v in fm.items()
    )
    fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
        "content": f"---\n{lines}\n---\n\nBody.\n", "sha": slug}


def test_facets_and_counts_returned(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_default_fields_schema({
            "priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
            "tags": {"type": "tags"},
        })
        _seed(fake, "a", priority="P0", tags=["checkout"])
        _seed(fake, "b", priority="P0", tags=["cart"])
        _seed(fake, "c", priority="P1", tags=["checkout", "cart"])
        _refresh()

        res = client.get(f"/api/projects/{_pid()}/rfcs")
        assert res.status_code == 200
        body = res.json()
        assert {i["slug"] for i in body["items"]} == {"a", "b", "c"}
        assert body["facets"]["priority"] == {"P0": 2, "P1": 1}
        assert body["facets"]["tags"] == {"checkout": 2, "cart": 2}
        assert body["facets"]["state"] == {"active": 3}


def test_filter_params_compose(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_default_fields_schema({
            "priority": {"type": "enum", "values": ["P0", "P1"]},
            "tags": {"type": "tags"},
        })
        _seed(fake, "a", priority="P0", tags=["checkout"])
        _seed(fake, "b", priority="P0", tags=["cart"])
        _seed(fake, "c", priority="P1", tags=["checkout"])
        _refresh()

        res = client.get(
            f"/api/projects/{_pid()}/rfcs",
            params={"priority": "P0", "tags": "checkout"})
        assert {i["slug"] for i in res.json()["items"]} == {"a"}


def test_unknown_filter_field_400(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_default_fields_schema(
            {"priority": {"type": "enum", "values": ["P0"]}})
        _seed(fake, "a", priority="P0")
        _refresh()

        res = client.get(f"/api/projects/{_pid()}/rfcs",
                         params={"nonsense": "x"})
        assert res.status_code == 400


def test_malformed_toggle(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_default_fields_schema(
            {"priority": {"type": "enum", "values": ["P0", "P1"]}})
        _seed(fake, "good", priority="P0")
        _seed(fake, "bad", priority="P9")  # not in values → malformed (INV-3)
        _refresh()

        res = client.get(f"/api/projects/{_pid()}/rfcs",
                         params={"malformed": "true"})
        slugs = {i["slug"] for i in res.json()["items"]}
        assert slugs == {"bad"}


def test_no_schema_no_facets(app_with_fake_gitea):
    # INV-5: the default document collection (no fields) returns empty facets.
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _seed(fake, "plain", tags=["whatever"])
        _refresh()
        body = client.get(f"/api/projects/{_pid()}/rfcs").json()
        assert body["facets"] == {}


def test_meta_json_persisted_at_ingest(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app):
        _seed(fake, "withmeta", priority="P0", tags=["x"])
        _refresh()
        row = db.conn().execute(
            "SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'"
        ).fetchone()
        meta = json.loads(row["meta_json"])
        assert meta["priority"] == "P0"
        assert meta["tags"] == ["x"]
```

If `load_config()` exposes the default project id under a different attribute, adapt `_pid()` (grep `default_project_id` in `app/config.py` / the existing vertical tests to confirm; `test_s1_collection_grain_vertical.py` resolves the same project id — copy its approach).

- [ ] **Step 2: Run the integration tests**

Run: `cd backend && python -m pytest tests/test_facets_api.py -q`
Expected: PASS (6 tests). If `_seed`'s frontmatter rendering trips on list values, simplify by writing the YAML with `yaml.safe_dump` instead of hand-built lines.

- [ ] **Step 3: Run the full backend suite for regressions**

Run: `cd backend && python -m pytest -q`
Expected: PASS (the prior green count was 601; new tests add to it). Fix any test that asserted an exact item-key set or the absence of `facets`.

- [ ] **Step 4: Commit**

```bash
git add backend/tests/test_facets_api.py
git commit -m "test(slice3): integration tests for faceted list endpoint (§22.4a PUC-3)"
```

---

## Task 6: Frontend API client — filtered list + facets

**Files:**
- Modify: `frontend/src/api.js` (`listRFCs` at ~206)

- [ ] **Step 1: Read the current `listRFCs`**

Confirm its current shape (around line 206) — it takes `(projectId, collectionId)` and returns the parsed JSON. Note the helper it uses to build the scoped URL (project- vs collection-scoped path) so the filter variant reuses it.

- [ ] **Step 2: Extend `listRFCs` to accept selections and build a query string**

```js
// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] }
// (each non-empty array becomes repeated query params, OR within the field);
// `opts.malformed` toggles the malformed-only filter. Returns the full payload
// { items, facets } — callers read both. With no selections it behaves as the
// pre-SLICE-3 list (and a no-fields collection returns facets: {}).
export async function listRFCs(projectId, collectionId, opts = {}) {
  const { selections = {}, malformed = false } = opts
  const qs = new URLSearchParams()
  for (const [field, values] of Object.entries(selections)) {
    for (const v of values) qs.append(field, v)
  }
  if (malformed) qs.set('malformed', 'true')
  const query = qs.toString()
  const base = /* the existing scoped-URL builder this function already uses */
  const url = query ? `${base}?${query}` : base
  return getJSON(url) // reuse the existing fetch/parse helper this file uses
}
```

Adapt `base` and the fetch helper name to whatever `api.js` already uses (it has a shared request helper — match the surrounding functions). The return value stays the parsed body, now `{ items, facets }`; existing callers that read `d.items` keep working, and `d.facets` is `undefined`/`{}` for the no-fields path.

- [ ] **Step 3: Run the frontend api tests**

Run: `cd frontend && npx vitest run src/api.collections.test.js`
Expected: PASS (existing api tests unaffected; `listRFCs` signature is backward-compatible — extra optional arg).

- [ ] **Step 4: Commit**

```bash
git add frontend/src/api.js
git commit -m "feat(slice3): listRFCs accepts facet selections, returns {items,facets} (§22.4a)"
```

---

## Task 7: `FacetGroups` component

**Files:**
- Create: `frontend/src/components/FacetGroups.jsx`
- Test: covered by `Catalog.test.jsx` (Task 9)

- [ ] **Step 1: Implement the facet-group renderer**

```jsx
// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1).
//
// Renders one collapsible group per facet field returned by the API, each with
// per-value result counts and multi-select checkboxes. A `tags`-type field gets
// a "filter values…" search box so it stays usable at 30+ values. Selection
// state + the malformed toggle are owned by the parent (Catalog), which
// re-fetches server-side on change.

import { useState } from 'react'

// Human label for the built-in state facet; declared fields use their own name
// (or the schema `label` when the API supplies it on `fields`).
function groupLabel(field, fields) {
  if (field === 'state') return 'State'
  const def = fields?.[field]
  return def?.label || field.charAt(0).toUpperCase() + field.slice(1)
}

function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) {
  const [open, setOpen] = useState(true)
  const [valueSearch, setValueSearch] = useState('')
  const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
  const needle = valueSearch.trim().toLowerCase()
  const shown = isTags && needle
    ? entries.filter(([v]) => v.toLowerCase().includes(needle))
    : entries
  return (
    <div className="facet-group">
      <button className="facet-header" onClick={() => setOpen(o => !o)}>
        <span>{open ? '▾' : '▸'} {groupLabel(field, fields)}</span>
      </button>
      {open && (
        <div className="facet-values">
          {isTags && entries.length > 8 && (
            <input
              className="facet-value-search"
              placeholder="filter values…"
              value={valueSearch}
              onChange={e => setValueSearch(e.target.value)}
            />
          )}
          {shown.length === 0 && (
            <div className="facet-empty">No values.</div>
          )}
          {shown.map(([value, count]) => (
            <label key={value} className="facet-value">
              <input
                type="checkbox"
                checked={selected.has(value)}
                onChange={() => onToggle(field, value)}
              />
              <span className="facet-value-label">{value}</span>
              <span className="facet-count">{count}</span>
            </label>
          ))}
        </div>
      )}
    </div>
  )
}

export default function FacetGroups({
  facets, fields, selections, onToggle, malformedOnly, onToggleMalformed,
  onClear, hasActiveFilters,
}) {
  // The API returns facets in field order with `state` last; render in that
  // order. `selections` is { field: Set<string> }.
  const fieldOrder = Object.keys(facets || {})
  // A `tags`-type field renders the value-search box.
  const isTags = (field) => fields?.[field]?.type === 'tags'
  return (
    <div className="facets">
      {hasActiveFilters && (
        <button className="facet-clear" onClick={onClear}>Clear filters</button>
      )}
      {fieldOrder.map(field => (
        <FacetGroup
          key={field}
          field={field}
          fields={fields}
          counts={facets[field]}
          selected={selections[field] || new Set()}
          onToggle={onToggle}
          isTags={isTags(field)}
        />
      ))}
      <label className="facet-malformed">
        <input
          type="checkbox"
          checked={malformedOnly}
          onChange={onToggleMalformed}
        />
        <span>Malformed metadata only</span>
      </label>
    </div>
  )
}
```

- [ ] **Step 2: Commit**

```bash
git add frontend/src/components/FacetGroups.jsx
git commit -m "feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1)"
```

---

## Task 8: Wire faceted mode into `Catalog.jsx`

**Files:**
- Modify: `frontend/src/components/Catalog.jsx`
- Modify: `frontend/src/App.css` (facet styles)

- [ ] **Step 1: Add facet state and gate on `fields`**

In `Catalog.jsx`, add state and read the collection's `fields` from `getCollection`:

```jsx
  const [fields, setFields] = useState(null)        // collection field schema
  const [facets, setFacets] = useState({})          // { field: { value: count } }
  const [selections, setSelections] = useState({})  // { field: Set<string> }
  const [malformedOnly, setMalformedOnly] = useState(false)
  const [loading, setLoading] = useState(false)
```

In the `getCollection(...)` `.then`, also `setFields(c?.fields || null)`.

A collection is in faceted mode when `fields` is a non-empty object:

```jsx
  const faceted = !!fields && Object.keys(fields).length > 0
```

- [ ] **Step 2: Re-fetch server-side when selections change (faceted mode)**

Replace the single `listRFCs(pid, cid)` call with an effect that, in faceted mode, passes the current selections + malformed toggle and stores both items and facets:

```jsx
  useEffect(() => {
    setLoading(true)
    const selObj = Object.fromEntries(
      Object.entries(selections).map(([f, set]) => [f, [...set]])
    )
    listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
      .then(d => { setRfcs(d.items); setFacets(d.facets || {}) })
      .catch(() => { setRfcs([]); setFacets({}) })
      .finally(() => setLoading(false))
    // proposals + caps load once per collection (unchanged), keep their own effect
  }, [version, pid, cid, selections, malformedOnly])
```

Keep the existing proposals + `getCollection` caps fetch in its own effect keyed on `[version, pid, cid]` (selections must not re-fetch caps/proposals). Move the `listProposals` + `getCollection` calls out of the selections-keyed effect.

- [ ] **Step 3: Toggle + clear handlers**

```jsx
  function toggleFacet(field, value) {
    setSelections(prev => {
      const next = { ...prev }
      const set = new Set(next[field] || [])
      set.has(value) ? set.delete(value) : set.add(value)
      if (set.size === 0) delete next[field]
      else next[field] = set
      return next
    })
  }
  function clearFilters() { setSelections({}); setMalformedOnly(false) }
  const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly
```

- [ ] **Step 4: Render the faceted pane vs legacy chips**

In the render, replace the `catalog-chips` block with a conditional:

```jsx
      {faceted ? (
        <FacetGroups
          facets={facets}
          fields={fields}
          selections={selections}
          onToggle={toggleFacet}
          malformedOnly={malformedOnly}
          onToggleMalformed={() => setMalformedOnly(m => !m)}
          onClear={clearFilters}
          hasActiveFilters={hasActiveFilters}
        />
      ) : (
        <div className="catalog-chips">
          {STATE_CHIPS.map(chip => (
            <button
              key={chip.id}
              className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
              onClick={() => toggleChip(chip.id)}
            >
              {chip.label}
            </button>
          ))}
        </div>
      )}
```

Update the client-side `filtered` memo so the state-chip narrowing applies **only in legacy mode** (in faceted mode the server already filtered by state):

```jsx
  const filtered = useMemo(() => {
    const needle = search.trim().toLowerCase()
    let items = rfcs.filter(r => {
      if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false
      if (!needle) return true
      const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
      return hay.includes(needle)
    })
    // …existing sort unchanged…
    return items
  }, [rfcs, search, sort, activeChips, faceted])
```

- [ ] **Step 5: Empty / loading / malformed-marker states (§5.1)**

In the list area, when `faceted && rfcs.length === 0` and filters are active, show "No entries match" + a clear-filters affordance; show a loading hint while `loading`. Add a small "malformed" marker on a row when `r.metadata_malformed`:

```jsx
                {r.metadata_malformed && (
                  <span className="row-malformed" title="Metadata fails this collection's schema">⚠ malformed</span>
                )}
```

Import `FacetGroups` at the top: `import FacetGroups from './FacetGroups.jsx'`.

- [ ] **Step 6: Facet styles**

Add minimal styles to `frontend/src/App.css` (match the existing `.catalog-*` look — borrow padding/colors from `.catalog-chips` / `.catalog-row`):

```css
.facets { padding: 8px 14px; border-bottom: 1px solid #eee; }
.facet-group { margin-bottom: 8px; }
.facet-header { background: none; border: 0; font-weight: 600; cursor: pointer; padding: 4px 0; width: 100%; text-align: left; }
.facet-values { display: flex; flex-direction: column; gap: 2px; }
.facet-value { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.facet-value-label { flex: 1; }
.facet-count { color: #999; font-variant-numeric: tabular-nums; }
.facet-value-search { width: 100%; margin: 4px 0; font-size: 12px; }
.facet-clear { background: none; border: 0; color: #06c; cursor: pointer; font-size: 12px; padding: 0 0 6px; }
.facet-empty { color: #aaa; font-size: 12px; }
.facet-malformed { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-top: 6px; color: #a60; }
.row-malformed { color: #a60; font-size: 11px; }
```

- [ ] **Step 7: Manual sanity check (build)**

Run: `cd frontend && npm run build`
Expected: build succeeds (no JSX/import errors).

- [ ] **Step 8: Commit**

```bash
git add frontend/src/components/Catalog.jsx frontend/src/App.css
git commit -m "feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1)"
```

---

## Task 9: Frontend component test for the faceted pane

**Files:**
- Create: `frontend/src/components/Catalog.test.jsx`

- [ ] **Step 1: Write the Vitest component test (PUC-3 behaviour)**

Mock `../api` so `getCollection` returns a `fields` schema and `listRFCs` returns `{items, facets}`. Follow the `Directory.test.jsx` mocking pattern (mock `../api`, wrap in `MemoryRouter`). Assert:
- facet groups render with per-value counts (e.g. "P0" with its count badge);
- a `tags`-type field with >8 values shows the "filter values…" box and it narrows displayed values;
- clicking a checkbox calls `listRFCs` again with the selection in `opts.selections`;
- a collection with no `fields` renders the legacy state chips (Super-draft / Active), not facet groups.

```jsx
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

const listRFCs = vi.fn()
const getCollection = vi.fn()
vi.mock('../api', () => ({
  listRFCs: (...a) => listRFCs(...a),
  listProposals: () => Promise.resolve({ items: [] }),
  getCollection: (...a) => getCollection(...a),
}))
vi.mock('../lib/entryPaths', () => ({
  entryPath: () => '/x', proposalPath: () => '/p',
  useProjectId: () => 'ohm', useCollectionId: () => 'default',
}))
import Catalog from './Catalog.jsx'

const renderCatalog = () =>
  render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)

describe('Catalog faceted pane', () => {
  beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset() })

  it('renders facet groups with counts when the collection declares fields', async () => {
    getCollection.mockResolvedValue({
      fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
      viewer: { can_contribute: false }, entry_noun: 'RFC',
    })
    listRFCs.mockResolvedValue({
      items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
      facets: { priority: { P0: 3, P1: 1 }, state: { active: 4 } },
    })
    renderCatalog()
    expect(await screen.findByText('P0')).toBeInTheDocument()
    expect(screen.getByText('3')).toBeInTheDocument()
  })

  it('re-fetches with the selection when a facet checkbox is toggled', async () => {
    getCollection.mockResolvedValue({
      fields: { priority: { type: 'enum', values: ['P0'] } },
      viewer: {}, entry_noun: 'RFC',
    })
    listRFCs.mockResolvedValue({ items: [], facets: { priority: { P0: 2 }, state: {} } })
    renderCatalog()
    const box = await screen.findByText('P0')
    fireEvent.click(box.closest('label').querySelector('input'))
    await waitFor(() => {
      const last = listRFCs.mock.calls.at(-1)
      expect(last[2].selections.priority).toContain('P0')
    })
  })

  it('renders legacy state chips when the collection declares no fields', async () => {
    getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
    listRFCs.mockResolvedValue({ items: [], facets: {} })
    renderCatalog()
    expect(await screen.findByText('Super-draft')).toBeInTheDocument()
    expect(screen.getByText('Active')).toBeInTheDocument()
  })
})
```

- [ ] **Step 2: Run the component test**

Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx`
Expected: PASS (3 tests). Adjust selectors to the exact markup produced in Task 7/8 if a query misses.

- [ ] **Step 3: Run the full frontend suite**

Run: `cd frontend && npm run test:run`
Expected: PASS (no regressions).

- [ ] **Step 4: Commit**

```bash
git add frontend/src/components/Catalog.test.jsx
git commit -m "test(slice3): Catalog faceted-pane component test (§22.4a PUC-3)"
```

---

## Task 10: Version bump, changelog, and ship

**Files:**
- Modify: `VERSION`, `frontend/package.json` (`version`), `CHANGELOG.md`
- Optional doc touch: `SPEC.md` §7.1 forward-pointer (review only)

- [ ] **Step 1: Bump the version (minor — new, additive, opt-in functionality)**

Set `VERSION` to `0.49.0` and mirror `frontend/package.json#version` to `0.49.0` (the §20 mirror rule).

- [ ] **Step 2: Add the changelog entry with upgrade steps**

Prepend a `## 0.49.0 — 2026-06-07` section to `CHANGELOG.md` describing SLICE-3: the faceted left pane for collections that declare a `fields:` schema, the list endpoint's `facets` + filter params + `meta` per item + 400-on-unknown-field, and migration 034. Non-breaking and opt-in (INV-5: a no-`fields:` collection — the default `document` — is unchanged, so N=1 sees nothing new). Upgrade steps:

```markdown
**Upgrade steps**

- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
  (additive, nullable). It is populated lazily as the corpus reconciler /
  content-repo webhooks re-ingest each collection; until an entry is re-ingested
  its `meta_json` is NULL and it contributes no facet values. Operators wanting
  facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
  on next startup does this). **No data migration is required.**
- A deployment opts a collection into faceting by declaring an
  `enum`/`tags` `fields:` block in its `.collection.yaml` (SLICE-2). A collection
  with no `fields:` keeps the existing state-chip catalog unchanged.
```

- [ ] **Step 3: Review `SPEC.md` §7.1 (left-pane filter)**

Read `SPEC.md` §7.1. If it describes only the chip filter, add a one-line forward-pointer noting that a collection with a `fields:` schema replaces the chips with faceted groups (§22.4a / the configurable-collection-metadata design). If §7.1 is already general, leave it. Keep this minimal — the binding contract for the slice is the design doc; no §22.4a contract change is needed (SLICE-0 already amended it).

- [ ] **Step 4: Final full test run (both suites)**

Run:
```bash
cd backend && python -m pytest -q
cd ../frontend && npm run test:run && npm run build
```
Expected: both green; frontend builds.

- [ ] **Step 5: Commit and open the PR**

```bash
git add VERSION frontend/package.json CHANGELOG.md SPEC.md
git commit -m "release(slice3): v0.49.0 — faceted left-pane filtering (§22.4a SLICE-3)"
```

Then push the branch and open a PR on `origin` (git.wiggleverse.org, Gitea) titled `v0.49.0 — SLICE-3: faceted left-pane filtering (§22.4a)`, body summarising the slice + linking the design doc. Per the autonomous default, merge once green (the framework's canonical PR flow is Gitea on `origin`).

---

## Self-review checklist (completed by plan author)

- **Spec coverage:** §6.4 list interface → Tasks 4–5 (facets, filter params, 400, `meta`). §5.1 left pane (groups, counts, tag-value search, states, malformed marker) → Tasks 7–8. §6.3 derived index (per-entry values) → Tasks 1–2. PUC-3 → Tasks 5 + 9. INV-5 (no-fields unchanged) → decision 1 + Tasks 4/8 + tests `test_no_schema_no_facets` / legacy-chips test. INV-3 (malformed never hidden, marker) → Task 8 step 5 + `test_malformed_toggle`. INV-7 (unknown keys preserved) → already held by SLICE-1; `meta_json` stores the full mapping so they survive. SLICE-3 DoD (facet counts + filter params incl. malformed; groups + counts + tag-value search; filters compose) → all covered.
- **Deferred (logged in the transcript):** Playwright E2E (no faceted-collection seeding fixture; §9 references rather than automates E2E) — PUC-3 covered by API integration + Vitest component tests. `text`-type fields not faceted in v1.
- **Placeholder scan:** none — every code step shows the code; the two adapt-to-existing notes (`api.js` URL builder in Task 6, `_pid()` attribute in Task 5) point at concrete existing call sites to copy.
- **Type/name consistency:** `meta_json` column, `facets.facet_fields` / `allowed_filter_keys` / `filter_and_count`, `listRFCs(pid, cid, {selections, malformed})`, `FacetGroups` props (`facets, fields, selections, onToggle, malformedOnly, onToggleMalformed, onClear, hasActiveFilters`) are used identically across backend, API, and frontend tasks.
