# Epic: Immutable Page Pipeline — Snapshots All The Way Down

**Status**: Complete — Sprints 0-6 landed and mutable `Page` is deleted
**Created**: 2026-04-06
**Updated**: 2026-05-30
**Target**: v0.4.x
**Estimated Effort**: 60-80 hours
**Dependencies**: Root roadmap sequencing; older protocol and architecture audit epics are archived as stale/superseded.
**Source**: Layered Review (2026-04-06), rfc-bengal-v2-architecture.md (Phase 3-4)
**2026-05-30 Check**: `bengal/core/page/__init__.py` no longer defines or
exports `class Page`; `bengal/core/page/legacy.py` has been deleted. The
SourcePage adapter seam exists in `bengal/content/discovery/page_adapter.py`,
autodoc/orchestration virtual producers use `create_virtual_source_page()`, and
page behavior fixtures have migrated through SourcePage helpers or page-like
mocks. The 2026-05-29 public API decision retired the lazy `bengal.Page` and
`bengal.core.Page` compatibility re-exports. Boundary tests now enforce that
production code and tests do not import the concrete `Page` class. The adapter's
public type boundary returns `PageLike`, backed by SourcePage-derived runtime
state rather than the deleted mutable `Page` class. Focused and broader
Page/source/rendering gates pass, and `uv run ty check bengal/` now reports 531
diagnostics, down from the previous 537 floor. Clean proof at `ba37930b3` also
passed ruff, ruff format, dependency layers, and `git diff --check`. Full-suite
runs no longer show Page-related failures; the remaining clean-worktree failure
is an unrelated directive migration parity failure reproduced with
`pytest tests/migration/test_directive_edge_cases.py::test_edge_case_parity --randomly-seed=314926607 -n0`
and recorded in `plan/ROADMAP.md` as follow-up.
(Correction: this was later **resolved by #298** — the cause was an incomplete
directive render-cache key, not the "parser state leak" assumed at saga close.)

---

## Why This Matters

Bengal's Page object is a mutable bag that **14 distinct modules** write to across **~90+ assignment sites**. This creates:

1. **Ownership chaos** — no module owns the Page lifecycle; anyone can set any field at any time
2. **Dual data model** — mutable Page and frozen PageSnapshot coexist, with rendering reading from both
3. **900-line PageProxy** — mirrors ~50 Page properties for incremental builds; every new Page property requires a parallel implementation
4. **5-step metadata conversion** — YAML → dict → PageCore → dict → CascadeView → CascadingParamsContext
5. **Free-threading unsafety** — `@cached_property` writes to `__dict__` without synchronization (undefined behavior under Python 3.14)
6. **6 intermediate representations** per page (PageCore, Page/PageProxy, CascadeView, PageSnapshot, context dict, Renderer caches)

The fix: replace mutable Page with **typed immutable records** at each pipeline stage. Each stage produces a new frozen record. No mutation, no locks, no caches, no proxy.

### Evidence (from layered review)

| Layer | Key Finding | Proposal Assessment |
|-------|-------------|---------------------|
| Architecture | 14 modules mutate Page; ~90 mutation sites | **FIXES** — each stage owns its record |
| Flow | 6 intermediate representations; 4 full traversals | **FIXES** — 3 records, 2 traversals |
| Interaction | PageProxy duplicates 50 properties; metadata round-trips 5x | **FIXES** — proxy eliminated, metadata resolved once |
| Micro | `@cached_property` unsafe under free-threading; slot access 2-15x faster | **FIXES** — frozen+slots is ideal for 3.14 |

### Three Invariants

These must remain true throughout migration or we stop and reassess:

1. **Incremental builds stay O(changed).** Per-stage content-addressed memoization ensures unchanged pages reuse previous records.
2. **SitePlan stays decomposed.** No monolith — NavigationPlan, TaxonomyPlan, RenderSchedule are separate frozen types.
3. **Templates never break.** Each sprint ships with full template compatibility verified by the existing test suite.

---

## Target Architecture

```
Stage 1 — Discover
  Read files → SourcePage(source_path, frontmatter, content_hash)
  Lightweight. No raw content loaded yet.

Stage 2 — Plan
  All SourcePages → SitePlan(NavigationPlan, TaxonomyPlan, RenderSchedule, CascadeMap)
  Cross-page relationships resolved. Pure function.

Stage 3 — Parse
  SourcePage + CascadeMap → ParsedPage(…source fields, html, toc, toc_items, excerpt, metadata)
  Embarrassingly parallel. Content loaded and parsed here.

Stage 4 — Render
  ParsedPage + SitePlan + Templates → RenderedPage(output_path, html, dependencies)
  Pure function. No mutation of any input.

Stage 5 — Write
  RenderedPage[] → disk
```

---

## Sprint Structure

| Sprint | Focus | Effort | Risk | Ships Independently? |
|--------|-------|--------|------|---------------------|
| **0** | Design: record types, `get_page()` solution, incremental strategy | 6-8h | Low | ✅ COMPLETE |
| **1** | Introduce ParsedPage; rendering reads it instead of mutating Page | 12-16h | Medium | ✅ COMPLETE (PR #196-197) |
| **2** | Introduce RenderedPage; eliminate `page.rendered_html` mutation | 8-10h | Medium | ✅ COMPLETE (PR #198) |
| **3** | Decompose SitePlan; replace snapshot builder | 8-12h | Medium | ✅ COMPLETE (PR #198) |
| **4** | Introduce SourcePage; replace Page at discovery | 10-14h | High | ✅ COMPLETE (PR #199) |
| **5** | Delete PageProxy; cache stores typed records | 6-8h | Medium | ✅ COMPLETE (PR #200) |
| **6** | Delete Page class; cleanup, final migration | 6-8h | Medium | In progress |

Each sprint is a shippable PR. Later sprints can be deferred if earlier ones prove the model wrong.

---

## Sprint 0: Design & Validate

**Goal**: Finalize record type definitions, solve the three hardest problems on paper, get sign-off before writing code.

### Task 0.1 — Define frozen record types

Design `SourcePage`, `ParsedPage`, `RenderedPage` as frozen+slots dataclasses. Define exact field lists by auditing:
- Which fields are available after discovery (→ SourcePage)
- Which fields are available after parsing (→ ParsedPage)
- Which fields are produced by rendering (→ RenderedPage)

**Acceptance**: Type definitions written. Every current Page field mapped to exactly one record type.

### Task 0.2 — Design SitePlan decomposition

Split current `SiteSnapshot` into:
- `NavigationPlan` — prev/next/ancestors, nav trees, active trails
- `TaxonomyPlan` — tag→pages, category→pages mappings
- `RenderSchedule` — wave ordering, template groups, attention scores
- `CascadeMap` — pre-merged section cascade (replaces CascadeView)

**Acceptance**: Type definitions written. Every current SiteSnapshot field mapped to a plan type.

### Task 0.3 — Solve `get_page()` template function

`get_page()` currently parses content on-demand during rendering and mutates the page. Design the replacement:
- Option A: Pre-parse all pages before rendering; `get_page()` becomes a pure lookup into a `dict[Path, ParsedPage]`
- Option B: `get_page()` returns a lazy wrapper that triggers parsing on access (preserves current semantics but adds complexity)

Validate memory impact of Option A for 1,000 and 10,000 page sites.

**Acceptance**: Decision documented with memory benchmarks.

### Task 0.4 — Design per-stage memoization

Define how incremental builds reuse records:
- SourcePage: reuse if file hash unchanged
- SitePlan components: reuse if input SourcePages unchanged (hash of all source hashes)
- ParsedPage: reuse if SourcePage hash + parser version unchanged
- RenderedPage: reuse if ParsedPage hash + template hash unchanged

**Acceptance**: Cache key definitions for each stage. Benchmark showing incremental builds stay under 200ms for 1-page change in 500-page site.

### Task 0.5 — Design `__getattr__` for custom frontmatter

Templates access arbitrary frontmatter via `page.brand`, `page.sku`, etc. (product-jsonld.html uses 12 custom attributes). Frozen dataclasses don't support `__getattr__` by default.

Options:
- A: Add `__getattr__` that delegates to `metadata` dict (works but bypasses slots benefits for custom fields)
- B: Require templates to use `page.metadata.brand` or `page.params.brand` (breaking change)
- C: Use `__getattr__` on a thin wrapper, not the record itself

**Acceptance**: Decision documented. Template compatibility verified against all theme templates.

---

## Sprint 1: Introduce ParsedPage

**Goal**: Rendering consumes `ParsedPage` instead of mutating `Page`. Page still exists but rendering no longer writes to it.

### Task 1.1 — Create ParsedPage frozen dataclass

Define in `bengal/core/records.py` (new file):
```python
@dataclass(frozen=True, slots=True)
class ParsedPage:
    source_path: Path
    title: str
    html_content: str
    toc: str
    toc_items: tuple[TocItem, ...]  # TocItem is a frozen dataclass (level, id, text, children)
    excerpt: str
    meta_description: str
    metadata: MappingProxyType[str, Any]
    # ... all fields from current PageSnapshot
```

**Acceptance**: Type exists. Mypy/ty passes.

### Task 1.2 — Build ParsedPage in rendering pipeline

After markdown parsing in `RenderingPipeline._process_page_impl()`, construct a `ParsedPage` from the parsed results instead of mutating `page.html_content`, `page.toc`, etc.

**Files**: `bengal/rendering/pipeline/core.py`
**Acceptance**: ParsedPage constructed. Page fields still set (dual-write) for backward compatibility.

### Task 1.3 — Render context reads from ParsedPage

Modify `build_page_context()` to accept `ParsedPage` and read content/toc/metadata from it rather than from the mutable Page.

**Files**: `bengal/rendering/context/__init__.py`
**Acceptance**: Context builder uses ParsedPage. Templates produce identical output (diff test).

### Task 1.4 — CacheChecker produces ParsedPage

When cache hits, `CacheChecker` constructs a `ParsedPage` from cached data instead of mutating Page fields.

**Files**: `bengal/rendering/pipeline/cache_checker.py`
**Acceptance**: Cache-hit path returns ParsedPage. No Page mutation on cache hit.

### Task 1.5 — Remove Page mutation from rendering

Delete the dual-write from Task 1.2. Rendering no longer sets `page.html_content`, `page.toc`, `page._excerpt`, `page._meta_description`, `page._ast_cache`.

**Files**: `bengal/rendering/pipeline/core.py`, `bengal/rendering/pipeline/autodoc_renderer.py`
**Acceptance**: `rg 'page\.html_content\s*=' bengal/rendering/` returns zero hits. Full test suite passes.

---

## Sprint 2: Introduce RenderedPage ✅

**Goal**: Rendering produces `RenderedPage` instead of mutating `page.rendered_html` and `page.output_path`.
**Status**: Complete (dual-write). `RenderedPage` record is constructed in all render paths and
passed to `write_output()`. Page mutation retained for backward compatibility with
`json_accumulator`, `cache_checker.cache_rendered_output`, and autodoc paths.

### Task 2.1 — Create RenderedPage frozen dataclass ✅

```python
@dataclass(frozen=True, slots=True)
class RenderedPage:
    source_path: Path
    output_path: Path
    rendered_html: str
    render_time_ms: float
    dependencies: frozenset[str] = frozenset()
```

**File**: `bengal/core/records.py`

### Task 2.2 — Pipeline builds RenderedPage ✅

`RenderingPipeline._render_and_write()` constructs `RenderedPage` after template rendering
and `format_html()`. Passes it to `write_output()`. Dual-writes `page.rendered_html` for
backward compatibility.

**Files**: `bengal/rendering/pipeline/core.py`

### Task 2.3 — Write phase consumes RenderedPage ✅

`write_output()` accepts optional `RenderedPage` parameter. When provided, reads
`rendered_html` and `output_path` from the immutable record instead of from the
mutable Page object. `_track_and_record()` likewise accepts an `output_path` override.

**Files**: `bengal/rendering/pipeline/output.py`

### Task 2.4 — CacheChecker produces RenderedPage ✅

Both `try_rendered_cache()` and `try_parsed_cache()` construct `RenderedPage` records
and pass them to `write_output()`.

**Files**: `bengal/rendering/pipeline/cache_checker.py`

### Deferred to later sprint

- **Remove dual-write**: Eliminate `page.rendered_html = ...` once `json_accumulator`
  and `cache_checker.cache_rendered_output` are updated to accept `RenderedPage`.
- **WaveScheduler collects RenderedPage**: Worker threads return `RenderedPage` instead
  of mutated Page objects. Blocked on removing dual-write first.

---

## Sprint 3: Decompose SitePlan ✅

**Goal**: Replace monolithic `SiteSnapshot` with composed, focused plan types.
**Status**: Complete. SiteSnapshot decomposed into NavigationPlan, TaxonomyPlan, and
RenderSchedule. All consumers migrated. Flat fields removed. `SitePlan` alias added.

### Task 3.1 — Extract NavigationPlan ✅

`NavigationPlan` frozen dataclass contains `menus`, `nav_trees`, `top_level_pages`,
`top_level_sections`. Accessed via `snapshot.navigation.*`.

**Files**: `bengal/snapshots/types.py`, `bengal/snapshots/builder.py`

### Task 3.2 — Extract TaxonomyPlan ✅

`TaxonomyPlan` frozen dataclass contains `taxonomies`, `tag_pages`.
Accessed via `snapshot.taxonomy.*`.

**Files**: `bengal/snapshots/types.py`, `bengal/snapshots/builder.py`

### Task 3.3 — Extract RenderSchedule ✅

`RenderSchedule` frozen dataclass contains `topological_order`, `template_groups`,
`attention_order`, `scout_hints`, `templates`, `template_dependency_graph`,
`template_dependents`. Accessed via `snapshot.schedule.*`.

**Files**: `bengal/snapshots/types.py`, `bengal/snapshots/builder.py`

### Task 3.4 — Extract CascadeMap (deferred to Sprint 4)

CascadeSnapshot and CascadeView are already well-designed separate frozen types.
Eager cascade resolution into record metadata belongs to the SourcePage sprint.

### Task 3.5 — Compose SitePlan from components ✅

`SiteSnapshot` now composes `NavigationPlan`, `TaxonomyPlan`, and `RenderSchedule`
as component fields. Cross-cutting fields (`pages`, `sections`, `config`, etc.) stay
top-level. `SitePlan = SiteSnapshot` alias added for forward compatibility.

**Consumers migrated**: `scheduler.py`, `scout.py`, `speculative.py`, `templates.py`,
`renderer.py`, `build/__init__.py`, `tracer.py`, `block_diff.py`

---

## Sprint 4: Introduce SourcePage ✅

**Goal**: Discovery produces `SourcePage` alongside mutable `Page` (dual-write). This is the
highest-risk sprint.
**Status**: Complete (dual-write). `SourcePage` frozen record is constructed during content
discovery and attached to `Page` via `_source_page` bridge field. Cache integration promotes
`content_hash` to `file_hash` in cached PageCore entries. Dev server reconstructs SourcePage
on file change. Orchestration phases continue mutating mutable Page (full migration deferred
to Sprint 5-6).

### Task 4.1 — Create SourcePage frozen dataclass ✅

`SourcePage` frozen dataclass composes `PageCore` (same contract as `Page.core`) plus
discovery-specific fields: `raw_content`, `raw_metadata` (MappingProxyType), `content_hash`,
`is_virtual`, `lang`, `translation_key`. Property delegates for `source_path` and `title`.
`create_virtual_source_page()` factory function for virtual pages.

**File**: `bengal/core/records.py`

### Task 4.2 — ContentDiscovery produces SourcePage (dual-write) ✅

`_create_page()` builds `SourcePage` first via `_build_source_page()`, then constructs
mutable `Page` from it. `page._source_page = source_page` bridge field enables downstream
read access. Cache-hit path (`_create_page_surgical()`) unchanged — PageProxy pages have
`_source_page = None`.

**Files**: `bengal/content/discovery/content_discovery.py`, `bengal/core/page/__init__.py`

### Task 4.3 — Cache integration reads SourcePage.core ✅

`phase_cache_metadata()` promotes `content_hash` from `SourcePage.core` to the normalized
`PageCore` before caching via `dataclasses.replace()`. Cache entries now include `file_hash`
populated at discovery time.

**File**: `bengal/orchestration/build/initialization.py`

### Task 4.4 — Virtual page creation via factory functions ✅

`create_virtual_source_page()` factory defined in `bengal/core/records.py`. Sprint 4
left existing mutable virtual-page callers unchanged, but Sprint 6 later migrated
autodoc/orchestration producers to `create_virtual_source_page()` and removed the
`Page.create_virtual()` compatibility constructor.

### Task 4.5 — Dev server hot-reload reconstructs records ✅

`ReactiveContentHandler.handle_content_change()` reconstructs `SourcePage` via
`dataclasses.replace()` when `_source_page` is present. Existing Page mutations kept
for backward compatibility.

**File**: `bengal/server/reactive/handler.py`

### Deferred to later sprint

- **Eliminate orchestration mutations**: Section finalization, taxonomy, related posts,
  complexity estimation continue mutating mutable Page. Full migration to SourcePage-only
  is Sprint 5-6 scope.
- **Wire `create_virtual_source_page()` into callers**: Completed in Sprint 6 for
  page producers; section virtual creation remains a separate Section concern.
- **Remove dual-write**: Eliminate Page construction from `_create_page()` once all
  consumers read from SourcePage.

---

## Sprint 5: Delete PageProxy ✅

**Goal**: Cache stores `ParsedPage` records. No proxy needed.
**Status**: Complete. `ParsedPage` serialization formalized with `to_cache_dict()`/`from_cache_dict()`.
Incremental discovery reconstructs full `Page` objects from cached `PageCore` + `ParsedPage`
instead of creating `PageProxy`. `proxy.py` (906 lines) deleted along with all references
across 22 production files and 9 test files. `build_raw_metadata_from_core()` utility
extracted to `bengal/core/page/utils.py`. Page gets `_from_cache` flag for metrics.

### Task 5.1 — Cache serialization for ParsedPage ✅

Added `to_cache_dict()` and `from_cache_dict()` to `ParsedPage` in `bengal/core/records.py`.
Added `get_parsed_page()` and `store_parsed_page()` to `ParsedContentCacheMixin`.
Refactored `CacheChecker.try_parsed_cache()` to use `ParsedPage.from_cache_dict()`.

**Files**: `bengal/core/records.py`, `bengal/cache/build_cache/parsed_content_cache.py`,
`bengal/rendering/pipeline/cache_checker.py`

### Task 5.2 — Incremental discovery loads cached ParsedPage ✅

Threaded `build_cache` parameter from orchestration through `discover()` →
`_discover_surgical()` → `_create_page_surgical()`. New `_create_page_from_cache()`
method constructs a fully-populated `Page` from `PageCore` + `ParsedPage` with zero
disk I/O. Falls through to full parse when parsed cache misses.

**Files**: `bengal/content/discovery/content_discovery.py`, `bengal/orchestration/content.py`,
`bengal/core/page/__init__.py`, `bengal/core/page/utils.py`

### Task 5.3 — Delete PageProxy ✅

Deleted `bengal/core/page/proxy.py` (906 lines). Removed all `isinstance(page, PageProxy)`
checks (8 sites) and imports (19 files). Updated `phase_update_site_pages()`,
`incremental_update_taxonomies()`, and feature detection to work without proxy.

### Deferred to later sprint

- **Remove Page compatibility adapter**: `page_from_source_page()` now isolates
  mutable `Page` construction at the discovery boundary; it can go only after
  downstream code consumes records directly.
- **Finish virtual-page migration**: Autodoc and orchestration producers use
  `create_virtual_source_page()`, and Sprint 6 removed `Page.create_virtual()`.

---

## Sprint 6 Saga: Delete Page Class

**Goal**: Remove the mutable Page dataclass entirely without losing public
behavior accidentally. This is a saga-scale goal: task commits may migrate one
domain or test cluster at a time, but the saga is not complete until the class,
production adapter, obsolete mixins, and non-compatibility test dependencies are
gone or explicitly retained by a recorded public API decision.

### Completed Sprint 6 slices

- `ee3427abd docs: codify saga planning workflow`
- `f50073af1 core: isolate page compatibility boundary`
- `76a8eee30 tests: migrate analysis page fixtures`
- `tests: migrate health and cache page fixtures`
- `tests: migrate orchestration page fixtures`
- `tests: migrate utility and template page fixtures`
- `tests: migrate nav tree page fixtures`
- `tests: migrate discovery and redirect page fixtures`
- `tests: migrate autodoc virtual page fixtures`
- `tests: migrate section page fixtures`
- `tests: migrate core page-like behavior fixtures`
- `tests: migrate page behavior fixtures`
- `tests: migrate page bundle fixtures`
- `content: type source page adapter as page-like`
- `core: retire public page exports`
- `core: remove page virtual constructor`
- `content: move i18n discovery state into source records`
- `tests: remove dummy Page constructor noise`
- `content: lazy-load page compatibility adapter`
- `tests: remove core page package-root imports`
- `protocols: remove page raw-source state`
- `protocols: remove page section state`
- `protocols: remove page directive-link state`
- `protocols: remove page parsed-content state`
- `protocols: remove page archive context state`
- `protocols: remove page autodoc fallback state`
- `protocols: remove page prerendered html state`
- `protocols: remove page site state`
- `tests: migrate analysis graph page fixtures`
- `tests: migrate cache query page fixtures`
- `tests: migrate orchestration taxonomy page fixtures`
- `tests: migrate redirect page fixtures`
- `tests: migrate orchestration page fixtures`
- `tests: migrate section hierarchy page fixtures`
- `tests: migrate cascade page fixtures`
- `tests: migrate section ergonomic page fixtures`
- `core: extract page visibility helpers`
- `tests: migrate page visibility fixtures`
- `tests: migrate page url fixtures`
- `tests: migrate page url cache fixtures`
- `tests: migrate page navigation edge fixture`
- `tests: migrate navigation page fixtures`
- `tests: migrate component model page fixtures`
- `tests: migrate computed page fixtures`
- `core: extract page metadata helpers`
- `tests: migrate page record fixtures`
- `tests: migrate page hashability fixtures`
- `tests: remove legacy page cached-property fixtures`
- `tests: remove legacy page section-reference fixtures`
- `tests: migrate page initializer fixtures`
- `tests: migrate page frontmatter fixtures`
- `tests: remove mutable page test factory`
- `content: introduce source page runtime adapter`

### Sprint 6 epics

1. **Production boundary closure:** production construction and direct imports
   stay isolated to `bengal/content/discovery/page_adapter.py` until that
   adapter can be deleted.
2. **Test fixture migration:** migrate non-compatibility tests to
   SourcePage/page-record helpers and record migrated files in
   `tests/unit/content/test_page_construction_boundary.py`.
3. **Protocol/type convergence:** remove concrete `Page` annotations where the
   contract is page-like or record-like.
4. **Public compatibility decision:** recorded 2026-05-29; public
   `bengal.Page` and `bengal.core.Page` compatibility re-exports are retired.
5. **Class and mixin deletion:** delete the mutable class and obsolete files
   only once the earlier epics prove the path is clear.
6. **Collateral closure:** update roadmap, changelog, docs, and verification
   evidence after each task slice.

### Task 6.1 — Audit remaining Page references

Search all remaining `Page` imports. As of the 2026-05-29 export retirement slice,
`rg 'from bengal\.core\.page import Page\b' bengal` finds only
`bengal/content/discovery/page_adapter.py`, the intentional compatibility
adapter from immutable `SourcePage` records to mutable `Page`. The broader
`bengal/` + `tests/` sweep now finds 1 import site: the adapter. The public
`bengal.Page` and `bengal.core.Page` compatibility re-exports are retired and
`tests/unit/core/test_public_exports.py` proves they stay absent without loading
`bengal.core.page`. The boundary test now performs an AST import sweep so
multi-name imports cannot hide concrete `Page` usage. The adapter itself now
returns `PageLike` instead of concrete `Page`, leaving only the actual
compatibility construction internally concrete. Next, delete or replace the
adapter boundary so `bengal/core/page/` can be removed.

Task 6.1 also started retiring post-adapter mutations: discovery now derives
i18n `lang` and `translation_key` values before creating `SourcePage`, so the
adapter receives the correct immutable source state instead of setting those
fields afterward.

Task 6.1 also removed misleading local `Page`-named test doubles from
template-context and safe-access tests. Those tests are now recorded in the
construction boundary proof, so a broad `Page(` sweep points at the real
production adapter/class boundary instead of unrelated dummy objects.

Task 6.1 now removes the direct production import from the remaining adapter.
Importing `bengal.content.discovery.page_adapter` no longer imports
`bengal.core.page`; the compatibility class is loaded only when
`page_from_source_page()` is called.

Task 6.1 also removed the last test import from the `bengal.core.page` package
root and added a boundary guard that rejects any future
`from bengal.core.page import ...` code import. Page submodules such as
`page_core`, `frontmatter`, `bundle`, and `utils` remain intentional until their
own deletion/move slices land.

Task 6.3 started protocol convergence by removing `_source` from `PageLike`.
Production raw-content readers now use `bengal.content.page_source.get_raw_source()`,
which prefers immutable SourcePage-backed `_raw_content` and only falls back to
the legacy `_source` property while the adapter still exists.

Task 6.3 continued by removing `_section` from `PageLike`. Production section
read/write call sites now use `bengal.core.section.utils.get_page_section()` and
`set_page_section()`, leaving the private slot as a legacy compatibility detail
rather than a required page-like contract.

Task 6.3 also removed `_directive_links` from `PageLike`. Directive-collected
links are now accessed through rendering-owned helpers in
`bengal.rendering.page_operations`, so parser side-channel state is not part of
the structural page contract.

Task 6.3 also removed parsed-content cache fields from `PageLike`, including
`_ast_cache`, `_toc_items_cache`, `_excerpt`, and `_meta_description`.
Cache adapters now set those remaining mutable slots through compatibility
helpers, and rendering-owned `page_content` helpers remain the access boundary
for derived AST, TOC, excerpt, and meta-description state.

Task 6.3 also removed section archive context fields from `PageLike`, including
`_posts`, `_subsections`, `_paginator`, and `_page_num`. Existing section index
enrichment now writes archive and pagination context into page metadata, matching
the renderer's read path and leaving the private fields as legacy class details
only.

Task 6.3 also removed `_autodoc_fallback_template` from `PageLike`. Autodoc
fallback rendering now records `_autodoc_fallback_template` and
`_autodoc_fallback_reason` through page metadata, so generated-reference
fallback tagging no longer requires mutable page slots.

Task 6.3 also removed `prerendered_html` from `PageLike`. Rendering-owned helper
functions now read and write pre-rendered virtual page HTML, leaving the legacy
field as adapter state until the mutable Page class is deleted.

Task 6.3 also removed `_site` from `PageLike`. Content discovery and
orchestration now use page-site helpers for the remaining mutable compatibility
object, so site context remains legacy adapter state instead of a protocol
requirement.

Task 6.1/6.2 test fixture migration continued by moving analysis graph tests to
hashable `MockAnalysisPage` fixtures. These tests now exercise analysis
behavior without constructing the legacy mutable Page adapter unless the test is
specifically proving compatibility.

Task 6.1/6.2 test fixture migration also moved cache query-index tests to
page-like cache fixtures. Index extraction, persistence, incremental update,
and thread-safety coverage now run without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 test fixture migration continued through content-type,
related-posts, and taxonomy-incremental orchestration tests. These now use
hashable page-like mocks for orchestration inputs instead of constructing the
legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved redirect postprocess tests to
local page-like redirect fixtures for alias behavior, leaving PageCore alias
serialization covered without constructing the legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved render, taxonomy, section, and
incremental orchestration tests to the shared page-like mock. The mock now
accepts legacy raw-content argument names during migration without constructing
the legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved nav-tree tests to the shared
page-like mock for navigation nodes, version filtering, active trail overlays,
baseurl proxy handling, and cache behavior.

Task 6.1/6.2 test fixture migration also moved Section sorting, hashability,
index-collision, page-like input, and versioning tests to shared page-like
mocks. The mock now exposes metadata-backed `version` for Section version
filtering without constructing the legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved cascade and cascade-snapshot
tests to shared page-like mocks. Section cascade extraction and immutable
cascade snapshot behavior now run without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 test fixture migration also moved Section ergonomic helper tests
to shared page-like mocks. Recent-page, content-page, and tag-listing helper
coverage now runs without constructing the legacy mutable Page adapter.

Task 6.1/6.2/6.3 also extracted page-like visibility helpers to
`bengal.core.page_visibility`. Legacy Page visibility properties now delegate to
the helper module, core page caches use metadata-based helper checks, and
content-signal/site-visibility tests now exercise visibility behavior through
page-like mocks instead of constructing the legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved page visibility tests to the
shared page-like mock and the Page-package-independent visibility helpers.
Hidden, listings, sitemap, search, RSS, robots, render-environment, and AI
content-signal coverage now runs without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 test fixture migration also moved href and section page URL tests
to shared page-like URL mocks backed by `bengal.rendering.page_urls`. Baseurl,
absolute URL, output-path-derived URL, fallback URL, section collection URL, and
navigation link coverage now runs without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 test fixture migration also moved page URL cache-regression tests
to the shared page-like URL mock. Fallback non-caching, output-path-derived URL
recalculation, and numeric filename section URL coverage now assert the current
rendering URL helper cache names without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 test fixture migration also moved the standalone no-section page
navigation edge case to the shared page-like mock and
`bengal.core.page.navigation` helpers. Weight-order navigation integration
coverage still uses discovery-built pages to preserve production behavior.

Task 6.1/6.2 test fixture migration also moved navigation breadcrumb and parent
tests to shared page-like mocks with `bengal.core.page.navigation` and
`bengal.core.section.utils` helpers. Section URL, hierarchy, root, ancestor,
breadcrumb, and parent coverage now runs without constructing the legacy
mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved component model
metadata-normalization tests to `build_page_core()`. Layout, hero-style,
explicit variant priority, and custom props coverage now proves the immutable
PageCore construction path without constructing the legacy mutable Page adapter.

Task 6.1/6.2 test fixture migration also removed computed age, author, and
series property-wrapper duplicates. The existing direct helper coverage now
matches the module boundary without constructing the legacy mutable Page
adapter.

Task 6.1/6.2/6.3 also moved generated, assigned-template, content-type, and
variant inclusion behavior behind metadata helper functions. Legacy Page
properties delegate to those helpers while metadata tests exercise the helper
boundary without constructing the mutable Page adapter.

Task 6.1/6.2 test fixture migration also moved page-record migration
bridge-retirement coverage to the canonical SourcePage-backed test-page
adapter. Record migration tests no longer construct the legacy mutable Page
adapter directly.

Task 6.1/6.2 test fixture migration also moved hashability and deduplication
coverage to source-path-hashable page-like mocks. Set operations, dict keys,
taxonomy deduplication, and reconstruction cache lookups now prove the
source-path identity contract without constructing the legacy mutable Page
adapter.

Task 6.1/6.2 also removed obsolete legacy Page cached-property tests. Raw source
access, word-count, reading-time, meta-description, and excerpt behavior is now
owned by content source, computed-function, and rendering helper tests rather
than by mutable Page property-wrapper coverage.

Task 6.1/6.2 also removed obsolete legacy Page section-reference tests.
Section helper, registry, and virtual-section behavior is covered outside the
mutable Page `_section` descriptor surface, so the deleted tests no longer hold
the class in place.

Task 6.1/6.2 also moved PageInitializer tests to the canonical
SourcePage-backed test-page adapter. The tests still cover site and section
initialization behavior, output-path validation, generated pages, and error
quality, but no longer use legacy mutable Page constructor keyword names.

Task 6.1/6.2 also moved frontmatter integration tests to the canonical
SourcePage-backed test-page adapter. Typed frontmatter access, dict-style
template access, malformed tag normalization, and frontmatter cache behavior
remain covered without direct use of the legacy mutable test-page factory.

Task 6.1/6.2 then removed the unused legacy mutable test-page factory from the
shared page-record fixtures. Tests now route through SourcePage-backed helpers
or purpose-built page-like mocks, leaving no test helper that accepts legacy
mutable Page constructor keyword names.

Task 6.1/6.2 then replaced the production adapter's legacy Page construction
with a SourcePage-backed `RuntimePage` owned by the core page compatibility
boundary. The `bengal.core.page` package root is now a lightweight helper
package and no longer exports `Page`; `bengal.core.page.legacy` has been
deleted. Adapter import tests now prove the legacy module is absent and page
adaptation does not expose `Page`.

**Acceptance**: `rg 'from bengal.core.page import Page' bengal/` returns zero hits.

### Task 6.2 — Delete Page class and mixins

`bengal/core/page/legacy.py` (the isolated legacy Page class) has been removed
after helper-package imports and behavior tests proved no fallback path still
depends on it. Older mixin files listed in this task have already been removed
or folded into helper modules.

**Acceptance**: Page class deleted. Mixin files deleted. Focused and broader
Page gates pass. Clean full-suite runs show only unrelated directive migration
parser-state failures, recorded as follow-up outside the Page deletion closure
criteria.

### Task 6.3 — Update protocols

Update `PageLike` protocol to match the new record types. Or replace with a union type / protocol that SourcePage and ParsedPage both satisfy.

**Files**: `bengal/protocols/core.py`
**Acceptance**: Protocol updated. Type checker passes across codebase.

### Task 6.4 — Delete stale caches and locks

Remove `_init_lock`, `_warnings_lock`, `_load_lock`, all manual `_cache` fields, `update_frozen` helper (if no longer used).

**Acceptance**: `rg '_init_lock\|_load_lock\|_cache' bengal/core/page/` returns zero hits.

### Task 6.5 — Update documentation and architecture docs

Update `rfc-bengal-v2-architecture.md`, design principles, and any internal docs referencing the old Page model.

**Acceptance**: Docs reflect new architecture.

---

## Risk Register

| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Incremental builds regress to O(all) | Medium | High | Sprint 0 designs memoization before any code. Benchmark gate on each sprint. |
| Template breakage from custom frontmatter access | Medium | High | Sprint 0 solves `__getattr__`. Full template test suite gates every sprint. |
| `get_page()` memory spike (pre-parsing all pages) | Low | Medium | Sprint 0 benchmarks Option A. Fallback to lazy wrapper if memory exceeds 2x. |
| Sprint 4 blast radius too large | Medium | High | Can defer Sprints 4-6 indefinitely. Sprints 1-3 deliver value independently. |
| Virtual page / autodoc incompatibility | Low | Medium | Sprint 4.4 addresses explicitly. Autodoc has its own test suite. |
| Circular import breakage | Medium | Medium | Per project memory: Bengal has fragile circular imports. Each sprint runs full import smoke test. |

---

## Success Metrics

| Metric | Current | After Sprint 3 | After Sprint 6 |
|--------|---------|-----------------|-----------------|
| Modules that mutate Page | 14 | 6 (orchestration only) | 0 |
| Intermediate page representations | 6 | 4 | 3 |
| PageProxy lines | 900 | 900 | 0 |
| Lock sites on page objects | 15 | 8 | 0 |
| Manual cache fields on page | 6 | 3 | 0 |
| `@cached_property` on page | 12 | 12 | 0 |
| Free-threading safe | No (`cached_property` UB) | Partially | Yes |
| Warm incremental build (1-page change, 500 pages) | <200ms | <200ms | <200ms |

---

## Relationship to Existing Epics

- **Epic: Protocol Migration** (Phase 1) — prerequisite. PageLike protocol must be adopted before we can introduce new record types that satisfy it.
- **Epic: Architecture Audit Remediation** — Sprint 1-2 (core model purity, boundary violations) should land first to reduce circular import risk.
- **rfc-bengal-v2-architecture.md** Phase 3 (Service Extraction) — this epic supersedes the Page-related portions of Phase 3. Service extraction for Site can proceed in parallel.
- **rfc-effect-traced-incremental-builds.md** — Sprint 0.4 (per-stage memoization) is a stepping stone toward effect-traced builds. The content-addressed cache keys designed here feed directly into the Merkle DAG approach.
