Skip to content

docs: the docs rewrite — every page one kind, every block runs, the SQL beside every query (#698) - #777

Merged
0x054 merged 35 commits into
mainfrom
feat/docs-rewrite
Oct 21, 2026
Merged

0x054 merged 35 commits into
mainfrom
feat/docs-rewrite

Conversation

@0x054

@0x054 0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #698.

The docs rewrite: every page on the site is one kind, carries one running example, shows the result of every block and the SQL beside every query, and runs under a harness that fails the build when a snippet, a docstring example, a quoted migration file or a captured SQL block drifts from the code. 25 tickets, 211 files, +24,331 / −11,840.

docs/
├── pages/                       # 51 pages rewritten: get-started, guide (sessions-first), schema (loop /
│                                #   renames / squash / reference), how-to, concepts, API, project pages
├── examples/                    # 46 runnable scripts + Annotated twins + _preludes/ for inline fences
└── solutions/patterns/          # docs-page-conventions.md, docstring-conventions.md (the rules)
src/ferro/**                     # 334 docstrings as Google-style reference entries (I-14), runnable examples
tests/
├── _docs_harness.py             # {test=run prelude=<name>} fences, docstring examples, error checks
├── test_docs_examples.py        # every example script and inline fence runs
├── test_docstring_examples.py   # every docstring example runs against the reference prelude
├── test_docs_sql.py             # every ```sql block regenerated from its example's .sql() (#725)
├── test_docs_migrations.py      # every quoted migration file regenerated byte for byte
├── test_docs_site.py            # strict build; API entries under public paths; no markup defects
└── test_docs_lint.py            # I-8 lambda naming over docs/pages, docs/examples, src/ferro
AGENTS.md                        # I-8 (lambda-only), I-14 (docstrings) rewritten; conventions pointed to

What a reader gets, in order of what the audit found worst:

  • Facts match the code. The field type table is generated from the DDL lowering; the upgrade guide reaches 0.25; the FAQ, front page, contributing and licence lines are right; the Timestamps and Pagination how-tos use update() canonicalization and after()/before() instead of hand-rolled recipes.
  • Every block runs. Each embedded snippet shows its async with engines.session(): or sits under a sentence that says it runs in one; snippet ranges end below the assert or print that holds the result; inline fences run under a named prelude or say why they only compile.
  • The SQL is beside the query (Show captured SQL beside query and mutation examples (needs #700) #725): 132 blocks on the Queries, Aggregations, Relationships and Mutations guides, each opening with a marker the test regenerates from the example's own .sql() (or the statement log for an instance write), with SQLite / PostgreSQL tabs where more than placeholders differ.
  • An API reference that reads as one. No Sphinx roles, no dunder or __ferro_*__ members, every export under its public path, Returns:/Attributes: as tables, runnable Examples: on the shared Author/Post prelude.
  • Navigation maps to the product: six tabs, page descriptions, redirects for every moved page.

Merged into this branch on the way: main at f3e46f8 (#700's statement log and .sql(), #760's SecretStr fix), with the docstring harness made to load griffe statically so an imported ferro cannot hand it the compiled _core as a source file.

Evidence

  • Before: the quickstart's first copied block raised IndentationError and then RuntimeError: No database route; 280 literal :class: roles on the API pages; five public objects with no entry; 50 snippets ending above their result; four hand-simplified SQL blocks and none on Aggregations.
    After on 00dd767: zensical build --clean --strict clean; docs tests 506 passed (examples, docstring examples, site checks, lint, SQL regeneration); tests/test_docs_sql.py 223 passed on sqlite+postgres; python -m tests._docs_checks over docs/pages, docs/examples, src/ferro exit 0; full suite on the branch at 7c19ce7 (before Show captured SQL beside query and mutation examples (needs #700) #725, which changed docs and tests only) 5078 passed, 69 skipped, 2 xfailed on sqlite+postgres.

Merge Danger

Door: two-way for the site and the tests; one-way for readers who bookmarked old URLs (redirects cover every moved page).

Blast Radius: docs site, docstrings, CI's docs job

Published pages change wholesale; the docs CI job gains tests that fail on drift (a snippet that no longer runs, a docstring example that fails, a quoted migration file or SQL block that differs), so future code changes that alter rendered SQL or step files must regenerate the docs blocks. One behaviour fix outside docs: ferro --help keeps its <placeholders> (#702). The ferro.query package exports Terminal (new public name, typing only).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

0x054 and others added 30 commits October 19, 2026 13:11
…docs conventions notes (#703) (#727)

Closes #703

## What

AGENTS.md I-8 used to tell agents to order predicate styles "lambda >
`col()` > operator" and call operator style "compatible today". Both
were removed in 0.14, and `where()` raises `TypeError` on anything that
isn't a callable. I-8 now describes the API that exists:

```python
adults = await User.where(lambda user: user.age >= 18).all()
recent = await Post.order_by(lambda post: post.published_at, "desc").all()
```

It covers lambda predicates, the parameter named after the model in
lowercase singular, `order_by`'s string or lambda selector, and the
`python -m tests._docs_checks` lint from #704. A new **I-14** sets the
docstring rules: Google style as griffe parses it, a one-line infinitive
summary (a noun phrase for classes), every parameter in `Args:`, a
one-line docstring on every public field, Markdown autorefs, no ADR,
issue, codename or Rust references, lambda-style examples against the
`reference` prelude, and longer explanations moved into guides.

Two new notes in `docs/solutions/patterns/`:
- `docs-page-conventions.md` covers one kind per page, one running
example, snippet `end:` markers below the result, the session wrapper,
generated SQL (once #700 lands), the glossary file, and the one
Annotated idiom for twins.
- `docstring-conventions.md` shows each I-14 rule as a before/after
pair.

The Annotated idiom keeps the same `Field(...)` call as the Assignment
tab and assigns the default outside `Annotated`:

```python
# before (two idioms in today's twins)
id: Annotated[int | None, Field(default=None, primary_key=True)]
id: Annotated[int | None, FerroField(primary_key=True)] = None
# after
id: Annotated[int | None, Field(primary_key=True)] = None
```

I checked the reason the note gives. With the default inside
`Annotated`, `ty` reports `C(name="a")` as `missing-argument: id`. With
the default assigned outside, it passes.

## Verify

Ran the ticket's Verify block on `337bcbf`: passed. Full suite: 3296
passed, 339 skipped, 2 xfailed.
TDD: RED, the Verify greps against `origin/feat/docs-rewrite`'s
AGENTS.md: `grep -q '^## I-14'` exited 1, and `grep -nE 'slated for
deprecation|compatible today'` matched lines 433–434. GREEN: the Verify
block passed on `337bcbf` (4/4).
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- `docs/solutions/README.md` has no index of notes, only layout and
frontmatter rules, so it is unchanged.
- I-8 and I-14 name artifacts that #704 creates: `tests._docs_checks`,
the `reference` prelude, the `{test=run prelude=…}` fence marker and
`tests/test_docstring_examples.py`. They are attributed to #704 in the
text.
- The I-13 example in AGENTS.md used `lambda m:`. I renamed it to
`lambda message:` so the file follows its own I-8.

- [ ] Exit steps: issue status updates (#703 progress comment, closure
after merge into the integration branch) are complete before merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #702

## What

`ferro migrate rerecord --help` used to read "The step whose edit to
accept: :, as 0007:01." because help was rendered as Markdown and
`<migration>:<step>` was swallowed as an HTML tag. Both cyclopts apps
now use `help_format="plaintext"`, so it prints `<migration>:<step>`,
and `new` and `squash` keep `NN_<name>.up.sql`.

## Verify

Ran the ticket's Verify block on `57ea0c6`: passed.
TDD: RED `pytest tests/test_cli_help.py` failed for `migrate new`,
`squash`, `rerecord` (3 failed, 10 passed); GREEN same command passed
(13/13). Full suite: 3309 passed.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- `tests/test_cli_help.py` walks the command tree from
`app._meta_parent`, collects each command's summary, docstring and
parameter help, and asserts every `<placeholder>` is in that command's
`--help` (whitespace folded, since the table wraps).
- Exit-steps: issue status updates are complete (#702 closes with this
PR).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…s, lambda and error checks (#704) (#728)

Closes #704

## What

A docs fence can now run against a small seeded database. Mark it like
this on a page or in a docstring:

````markdown
```python {test=run prelude=reference}
print(await Author.where(lambda author: author.id == 1).first())
```
````

The block runs in a subprocess after
`docs/examples/_preludes/reference.py`, which declares `Author` and
`Post`, connects to in-memory SQLite, adds two authors and two posts,
and holds a session open. Top-level `await` works. If the block fails,
the traceback names the page and its line (`File
"docs/pages/guide/queries.md", line 42`).

`tests/test_docstring_examples.py` finds every Python fence in
`src/ferro` docstrings through griffe. It compiles each one and runs the
ones marked with a prelude.

`python -m tests._docs_checks PATH...` lists two mistakes and exits 1 if
it finds any:

```text
docs/examples/aggregations.py:217: lambda passed to select() takes one-letter parameter 't'; name it after the model in lowercase singular
docs/examples/aggregations.py:216: try/except asserts on the error but has no else that raises, so it passes when the error stops happening; add else: raise AssertionError("expected TypeError")
```

It is not wired into the suite yet. #724 makes it a gate.

## Verify

Ran the ticket's Verify block on `4c85007`: passed (26 passed;
`test_docs_examples.py` 234 passed).
TDD: RED `uv run --no-sync pytest tests/test_docs_checks.py
tests/test_docstring_examples.py -q --no-cov -p no:cacheprovider` failed
as expected (`ModuleNotFoundError: No module named
'tests._docs_harness'`, b593745); GREEN, the same command passed
(26/26).
Full suite: 3327 passed, 339 skipped, 2 xfailed.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Test scenarios

- Happy path: a `{test=run prelude=reference}` fence that queries
`Author` passes and prints `Ada Lovelace`.
- Error path: a fence that raises `RuntimeError` fails, and the message
names the page and line 5.
- Edge case: `lambda author:` passes, `lambda a:` fails with its line,
`sorted(key=lambda r: ...)` and `rows.sort(...)` are ignored.
- Edge case: `try/except` that asserts without `else` is reported; with
`else: raise AssertionError` it is not.
- Integration: a scratch module's docstring example marked `{test=run
prelude=reference}` is collected and runs.
- Added: an unknown prelude fails and names the available ones;
`prelude=` without `test=run` is refused; bare and quoted settings both
parse; a fence inside a content tab and a fence in a function docstring
are checked at their file lines; a default-captured lambda parameter
(`f=field`) is not a query parameter.

## Notes for the reviewer

- **Today's counts.** `python -m tests._docs_checks docs/examples`
reports 74 one-letter lambdas: 68 in the four scripts (`aggregations`
24, `partial_selects` 17, `populated_relations` 16, `existence_tests`
11) plus 6 in their twins. The audit counted 66. It reports **10**
unfailing error checks, not 12: `mutations.py:56` and `:113` catch the
error without asserting anything, so the ticket's rule ("an `except`
that asserts") does not cover them. Widening the rule to any `except`
that swallows a named error would flag legitimate fallbacks such as
`except ImportError`, so I kept the ticket's definition. The mutations
page ticket should give both blocks an `else: raise`.
- **The opt-ins never worked.** pytest-examples' `prefix_settings()`
only reads quoted values, so `{test=run}` and `{lint=check}` were
silently ignored. `fence_settings()` reads both spellings. No page used
them, so results are unchanged. Recorded in
`docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md`
(I-5).
- **Only supplied parameters are checked.** `lambda related, f=fk_field,
v=pk_val: ...` captures `f` and `v` by default, and the query only
passes the first, so only positional parameters without a default count.
- **The method list** comes from the `Query`, `ProjectedQuery`,
`Relation`, `Model` and `ModelConnection` signatures (including
overloads) whose parameters mention `Callable`, `Predicate` or
`RowSelector`, plus the names the ticket lists.
- **Markdown inline code spans** (`` `order_by(lambda t: ...)` `` in
prose) are not checked. The ticket scopes the checks to fences, and the
audit's 57 page lines include spans.

### Outside Files owned

- `tests/_docs_harness.py`: fence settings, fence extraction, docstring
collection and the prelude runner call. Both test modules and the checks
need them, and putting them in `_docs_checks.py` would mix the runner
into the checks module.
- `src/ferro/relations/descriptors.py`: renamed one internal lambda
parameter `t` to `related`, so `python -m tests._docs_checks src/ferro`
exits 0 and the docstring tickets start from a clean run.
-
`docs/solutions/issues/pytest-examples-prefix-settings-need-quotes.md`:
the I-5 note above.

## Exit steps

- [ ] #704 status updated (progress comment at `pr-open`; the
orchestrator closes it after the merge into `feat/docs-rewrite`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…729)

Closes #701

## What

Take a page that embeds `docs/examples/predicates.py:filtering`. Today
the reader sees

```text
        adults = await User.where(lambda user: user.age >= 18).all()
```

with eight leading spaces, because the example section sits inside
`async def main():`. After this PR it starts at column 0, so a pasted
block runs. The rest of the PR is configuration of the same kind:

- One click on "Annotated" switches every Assignment/Annotated pair on
the site, and tab anchors read `#defining-a-model-annotated` instead of
`#__tabbed_1_2`.
- Hovering DDL, RLS, FK, PK, GUC, FFI, IR and similar terms shows a
definition, from `docs/includes/abbreviations.md`, appended to every
page.
- `#647` in the changelog links to the issue. Permalinks carry a title
for screen readers.
- `/guide/migrations/#auto-migration` now lands on
`/guide/schema/auto-migrate/` through a generated redirect page. The
hand-written stub page is deleted, so it is gone from the sitemap and
search. All 16 old anchors are mapped.
- The API reference drops source blocks, puts typed and linked
signatures under each heading, and shows summaries, symbol badges and
backlinks. `Field` renders once, without its six overloads, and each
parameter gets an anchor (`ferro.Field(index)`). `Relation.all()` and
`.first()` inherit `Query`'s docstrings.
- Theme: instant navigation with a progress bar, anchor tracking,
breadcrumbs, prev/next footer, line selection, "Copy as Markdown", a
third "follow system" palette state, GitHub and PyPI footer links, and
`new`/`deprecated` status badges. `search.suggest` is removed because
nothing reads it.
- Plugins: `redirects`, `llmstxt` (`/llms.txt`, `/llms-full.txt`, a
`.md` copy of every page) and `social` cards on the rust background
`#B7410E` (checked: the card's first pixel is `b7410e`).
- `create`, `upsert`, `get_or_create` and `update_or_create` annotate
`**fields: Any`, which clears the griffe warnings.
- The docs build runs with `--strict` in the publish workflow and in a
new `docs` job in CI, which `All Checks Passed` now requires.

## Test scenarios

- Happy path: `tests/test_docs_site.py` builds the site with `zensical
build --strict` once per session in a temporary directory and passes (13
tests).
- Error path: with `dedent_subsections = false` the indentation test
fails and names `/getting-started/quickstart/` (` await
connect("sqlite::memory:", auto_migrate=True)`). With the griffe
extension removed, the `Relation` test fails because `Relation.all` has
no docstring. I ran both by hand and then restored the config.
- Integration: the generated `guide/migrations/` page maps every old
anchor to the section that replaced it. The test resolves each target
against the old URL and checks that the anchor exists on the target
page.

## Verify

Ran the ticket's Verify block on `d48d7cc`: passed (`No issues found`;
13 passed).
TDD: RED `uv run --no-sync pytest tests/test_docs_site.py -q --no-cov -p
no:cacheprovider` failed as expected on `13629e7` (3 failed, 10 errors:
no glossary, a non-strict build, the stub page present). GREEN: the same
command passed 13/13 on `d48d7cc`. Full suite: 3309 passed, 339 skipped,
2 xfailed.
Verify block corrected: no

## Interfaces

Interfaces changed: none

`built_site` (session fixture returning the site `Path`) and
`rendered_pages(built_site) -> Iterator[tuple[str, str]]` are in
`tests/test_docs_site.py`. `url_path` reads like `/guide/queries/`, and
`/` for the home page. Zensical refuses a `docs_dir` outside the project
root, so the fixture copies `zensical.toml`, `docs/pages` and
`docs/includes` into the temp project and symlinks `docs/examples`,
`src`, `README.md`, `CHANGELOG.md` and `CONTRIBUTING.md`.

## Notes for the reviewer

- **`filters = ["!^_"]`, not `"public"`.** The ticket asks for `filters
= "public"` and also for a Model page with no dunders or `__ferro_*__`
attributes, and one setting cannot deliver both. Griffe counts every
dunder as public, so `"public"` still rendered `__ferro_checks__`,
`__getattr__`, `__repr__`, `__post_init__` and the rest. `["!^_"]` hides
them. Every API directive names a class or function, never a module, so
`__all__` (the one thing `"public"` adds) does not come into play.
Site-wide this also hides protocol dunders a reader might care about:
`Session.__aenter__`, `Rows.__len__`/`__iter__`/`__getitem__`, and the
`AggregateExpr` comparisons. Those belong in the class docstrings, which
is #722's work. The reasoning is also in a comment in `zensical.toml`.
- **Outside Files owned:**
- `docs/pages/api/fields.md`: the `show_overloads: false` /
`parameter_headings: true` options the ticket asks for go on the `:::
ferro.Field` directive, which lives in that file.
- `ci.yml`'s two pytest jobs now also sync the `docs` group, so
`test_docs_site.py` runs in CI and is not skipped. The file uses
`pytest.importorskip("zensical")` for environments without the group.
- The strict build needed no docstring fixes.
- Duplicate `__codelineno-0-N` ids on API pages are still there.
`separate_signature` code blocks are all numbered from 0, which the
audit flagged as a separate ticket. Strict mode does not flag them.
- `llmstxt` still lists `getting-started/next-steps.md`. The nav ticket
(#723) folds that page away and should update the section list.

## Exit steps

- [ ] Issue #701 status updated (closed by the orchestrator after this
PR merges into `feat/docs-rewrite`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… quickstart (#705) (#730)

Closes #705

## What

A newcomer who copies the README, home page or quickstart now gets code
that runs as shown. Example from the README: `User.create(...)` and a
`User.where(lambda user: user.is_active == True)` query run inside
`async with engines.session():` and print `Found 1 active users.`, and a
test runs exactly that block.

- README and home page: one-sentence opening, real dependencies
(Pydantic and pydantic-settings), identity map described as
session-scoped, no tooling badges on the home page, and the model shown
in Assignment and Annotated tabs from `readme_quickstart.py` /
`readme_quickstart_annotated.py`.
- Why Ferro: migrations are built in (`ferro migrate`, plus an Alembic
bridge), atomic update expressions are no longer listed as missing (only
`*`, `/`, `.concat()`), the `lambda user` fix, sentence-case headings,
and a "Sync API" row replaces "Learning curve".
- Installation: output of `ferro.version()`, uv/pip tabs for both
extras, a venv-safe source build.
- Quickstart: session wrapper on the first working block, lead-in and
result sentence on every block, results asserted inside each snippet
range, `author=alice` throughout, UTC timestamps, and the three Next
steps paths folded into the closing section.

## Verify

Ran the ticket's Verify block on `f5cb510`: passed (strict build clean,
`tests/test_docs_examples.py` 239 passed, `_docs_checks` exit 0). Full
suite: 3358 passed, 339 skipped, 2 xfailed.
TDD: RED `pytest tests/test_docs_examples.py -k readme` failed with the
README's `is_active=False` changed to `True` (stdout no longer `Found 1
active users.`); GREEN: 4 passed after restoring it.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- Outside Files owned: `tests/test_docs_examples.py` gained two tests
(README block runs and prints its stated output; README model equals the
home page's embedded model). The ticket asks for the README to be run by
a test and names no test file.
- Audit rows answered rather than fixed: no generated SQL is shown
beside queries, since there is no public way to read a query's SQL yet
(conventions note this waits on #700/#725), and hand-typed SQL would
drift. The `# noqa: E712` comments stay: the per-file ruff ignore is a
config change outside this ticket. The installation version block is
compile-only (marker says why): `run_print_check` needs a hard-coded `#>
0.25.0` that would break each release.
- Audit finding that was wrong: none found; "AGENTS.md I-8 stale" is
flagged, not touched.
- Dedent: strict build passes with `dedent_subsections` on.
- Glossary terms needed: none new (GIL, FK already defined).
- `next-steps.md` left in place for #723.

- [ ] Issue status updates complete (I-9): `Closes #705` above.



## Fix report (review round 1)

- `bulk_create` stays inside `quickstart.py:create` again (the
`bulk-create` section is gone), so `guide/mutations.md` is right. The
quickstart page embeds `create` once and explains both calls. The
section names `models`, `create` and `update-delete` are unchanged; the
old `query` section is still split into `get`, `filter`, `aggregate`,
and no other page embeds it.
- The quickstart now shows an `imports` range, says the model block is
module level and every later block goes inside one `async def main()`
run with `asyncio.run(main())`, and points to the script that has that
shape.
- Dropped the unsupported "about ten minutes".
- Covering tests: `tests/test_docs_examples.py` (runs `quickstart.py`,
`quickstart_annotated.py` and the README tests) and
`tests/test_docs_site.py`. Command: `uv run --no-sync pytest
tests/test_docs_examples.py tests/test_docs_site.py -q --no-cov -p
no:cacheprovider -n 8` gave `252 passed`. Strict build: no issues.
`_docs_checks` over the owned files: exit 0. Verify SHA `2dd3783`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…731)

Closes #706

## What

The FAQ, roadmap and contributing pages now match the code. The FAQ says
`include()` loads forward foreign keys and links Raw SQL to its own
guide. `CONTRIBUTING.md` states Apache 2.0 and lists only channels that
exist. The roadmap gives every gap a workaround that runs.

Before and after, from the FAQ's eager-loading answer:

```
Before: "Does Ferro have eager loading? Not yet."
After:  include(lambda post: post.author) populates forward foreign keys in one
        statement; reverse and many-to-many collections are not populated yet.
```

## Audit findings

| Finding | Result |
| --- | --- |
| FAQ H eager loading | Fixed, with a running block. |
| FAQ H Discussions link | Removed. |
| FAQ M Raw SQL link | Now `guide/raw-sql.md`. |
| FAQ M "two doors" | Now "two ways, plus a bridge". |
| FAQ M/L wording, em dashes, subject-less sentence | Fixed. |
| FAQ M split Troubleshooting out | Left: a new page needs a nav entry
and a redirect, which this ticket does not own. Headings are keyed by
error text instead. |
| Roadmap M/L (lead with the gap, split bullet, sentence case) | Fixed.
Issue links read "tracked here" because the ticket bars issue numbers in
page text. `ilike()`, health checks, `disconnect()`, pool options and
`.concat()` have no issue; the page says so. |
| CONTRIBUTING H license, Discussions, docs URL | Fixed. |
| CONTRIBUTING M tree, PR rules, intro, checklist emoji | Fixed. New
"Project rules" section covers I-9, I-10, I-7, I-8, I-1, I-3. |
| Changelog L (H1 in caps) | Left: it comes from the release template,
not the page (I-10). The page already embeds `CHANGELOG.md` unchanged
and magiclink renders its `#NNN` as links (checked in the built site). |

Audit rows that were wrong or stale:

- The FAQ's `Relationship resolution failed` text is not in the code.
The real error is `Model 'P' defines a relationship to 'A' with
related_name='posts', but 'A' does not have that field defined as
BackRef()...`. The entry now quotes it.
- The FAQ had no entry for `No database route for this operation`, the
error a reader hits first now that sessions are required. Added.
- `cargo test` in CONTRIBUTING fails to link Python; CI runs `cargo test
--no-default-features --features testing`. Corrected (ran: 374 passed).

## Verify

Ran the ticket's Verify block on the final commit: strict build clean,
`tests/test_docs_examples.py` 243 passed, `_docs_checks` exit 0. Full
suite: 3362 passed, 339 skipped, 2 xfailed.
TDD: docs-only change. The tests are the harness fences on the three
pages and the new `faq.py` and `faq_annotated.py` scripts; every error
check has an `else: raise AssertionError`.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- Outside Files owned: new `docs/examples/faq.py` and
`docs/examples/faq_annotated.py` (the Assignment and Annotated tabs for
the running example, plus the multi-database snippet, which cannot run
under the session-opening prelude because `using=` conflicts with an
ambient session).
- The first block on the FAQ is the model tabs, then the first block
that does work shows `async with engines.session():`.
- The changelog page has no opening sentence of its own, since the
embedded file starts with its own H1.
- External links checked, all 200: `github.com/syn54x/ferro-orm`
(`LICENSE`, `AGENTS.md`, `issues`, issues 290, 291, 309),
`ferro-orm.x54.sh`, `conventionalcommits.org`, `sh.rustup.rs`,
`astral.sh/uv/install.sh`.
- No new glossary terms.

## Exit steps

- [ ] Issue status updated (#706 closes with this PR; I-9)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…732)

Closes #709

## Summary

The Queries guide and the typed-predicates page now run on one ledger,
name every lambda after its model, and show the result of every block.

Before, the guide used `User`, `Invoice`, `Card`, four different
`Transaction` models and models no page declared. After, one model set,
declared once at the top of the guide:

```text
Owner ──< Account ──< Transaction >── Category (parent → Category)
                          │  ├──< SplitLine >── Category
                          │  ├── Transfer.outflow / Transfer.inflow
                          │  └──< Tag (many-to-many)
```

Nine transactions a reader can check by eye (Salary, Rent, Market run,
the two halves of a transfer, two Lyon rows, Office coffee, Weekly
shop), shown as a table on the page.

```diff
 docs/examples/
+├── _ledger_seed.py          # the ledger's rows, shared by every query example
+├── _preludes/queries.py     # prelude for {test=run prelude=queries} blocks
 ├── predicates.py            # declares the ledger + filtering, ordering, paging
 ├── predicates_annotated.py  # Annotated twin of the ledger, runs a few queries
 ├── traversal.py             # imports the ledger from predicates.py
 ├── existence_tests.py       # imports the ledger
 ├── populated_relations.py   # imports the ledger; `basic` still embedded by relationships.md
 ├── partial_selects.py       # imports the ledger
-├── traversal_annotated.py
-├── existence_tests_annotated.py
-├── populated_relations_annotated.py
-└── partial_selects_annotated.py
```

The four deleted twins only declared models the page no longer embeds;
the one Annotated tab on the page is `predicates_annotated.py:models`
(AGENTS.md I-7).

Page changes, one line each:

- Predicate style is lambda only; the old three-styles story is a
"Changed in 0.14" note linking the upgrade guide. The parameter is named
after the model (`transaction`), never `t`.
- Every snippet's `end:` marker sits below its assert, so the page shows
which rows came back. Unordered results assert as sets.
- New section **Paging with `after()` and `before()`** (anchor
`#paging-with-after-and-before`, for #716): the four order-key rules,
`position_of()`, `before()` with and without a limit, `None` in a non-PK
slot.
- Every error check in the examples has `else: raise
AssertionError(...)`.
- Hand-written SQL blocks are deleted (captured SQL waits on #700 /
#725).
- Reference links on first use (`where()`, `order_by()`, `after()`,
`include()`, `select()`, `Row`, `Rows`, `Model.get`), and the concept
page's hand-written Reference list is now links.
- Explanation moved out of the guide: why a projection returns `Row`
lives in `concepts/query-typing.md#projected-queries`; PEP 827 is
explained once, under "Where checking stops".
- Headings are sentence-case noun phrases; incoming anchors from other
pages keep their slugs (checked in the built site).

## Evidence

- **Before:** `python -m tests._docs_checks docs/pages/guide/queries.md
docs/pages/concepts/query-typing.md docs/examples/` reported 57
violations in the owned files (one-letter lambdas and error checks that
cannot fail) and exited 1.
  **After:** the same check over the owned files exits 0.
- Renaming one lambda in `traversal.py` to `t` makes the check exit 1
with `docs/examples/traversal.py:27: lambda passed to where() takes
one-letter parameter 't'`.
- Replacing the colliding selector in `partial_selects.py:collision`
with the dict form makes the script fail with `AssertionError: two
fields named 'id' must raise ValueError`.

## Verify

Ran the ticket's Verify block on `602f38d`:

- `uv run --no-sync zensical build --clean --strict`: passed, no issues.
- `uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p
no:cacheprovider -n 8`: 224 passed (the five query scripts, the
Annotated twin, and five `{test=run prelude=queries}` blocks on the
concept page).
- `uv run --no-sync python -m tests._docs_checks
docs/pages/guide/queries.md docs/pages/concepts/query-typing.md
docs/examples/`: exits 1, every violation in `aggregations.py`,
`aggregations_annotated.py` and `mutations.py`, which sibling tickets
own. Over the owned files it exits 0.

Full suite: `uv run --no-sync pytest -n 8 --no-cov`: 3343 passed, 339
skipped, 2 xfailed.

TDD: RED `python -m tests._docs_checks <owned files>` failed as expected
(57 violations); GREEN the same command passed (exit 0).
Verify block corrected: yes: the third command scans all of
`docs/examples/`, which includes files owned by sibling tickets that
still fail; I ran it over the owned files (`docs/pages/guide/queries.md
docs/pages/concepts/query-typing.md
docs/examples/{predicates,predicates_annotated,existence_tests,partial_selects,populated_relations,traversal,_ledger_seed}.py
docs/examples/_preludes/queries.py`).

## Interfaces

Interfaces changed: none. Produces
`guide/queries.md#paging-with-after-and-before` as the ticket states.

## Notes for the reviewer

**Outside Files owned:**

- `docs/examples/_ledger_seed.py` (new): the rows, so the Assignment
twin, the Annotated twin, the four other scripts and the prelude add the
same data. Without it the seed would be copied six times.
- `docs/examples/_preludes/queries.py` (new): the brief's "add a new
prelude file when your page needs its own models".
- Deleted the four `*_annotated.py` twins of traversal, existence tests,
populated relations and partial selects (listed under Modify; nothing
embeds them now).

**Audit findings left or answered:**

- M "split the page into four": kept one page. The ticket owns only
these two pages, other pages link nine of its anchors, and the single
ledger removes the reason the split was proposed (no shared domain).
Splitting needs new nav entries and redirects, which is a separate
ticket.
- M "show the rendered SQL" (existence tests, traversal): deferred to
#725, per the ticket's non-goals; the hand-written blocks are deleted.
- L "ignore E711/E712 for `docs/examples/` in ruff config":
`pyproject.toml` is outside Files owned. E712 is gone (no `== True` /
`== False` left); `# noqa: E711` remains on `== None` lines.
- L "`Annotated["Employee | None", ...]` if supported": it is not;
`"Category | None"` fails with `RuntimeError: Relationship resolution
failed: 'Category | None' not found`. The self-reference keeps
`nullable=True`, and the page says why.
- L the audit's "`t.lines…` in prose" and the error-message tails:
fixed; the quoted messages are now asserted in the scripts.

**Found while checking facts, not fixed here (outside scope):**

- Library error messages still teach `t.` and cite internal records:
`where()`'s bare-relation and `and`/`or` messages (`t.account.<column>`,
`u.age`), the traversed-mutation message (`t.account == instance`), the
reverse-join and `in_(query)` messages (`ADR-0007`), and the
aggregate-in-`where()` message (`#291`). The pages assert only the
stable prefix of these. Worth a follow-up issue against
`src/ferro/query/`.
- On SQLite a `Decimal` reads back without trailing zeros
(`Decimal("-1450.00")` returns `Decimal('-1450')`); the first block's
printed output shows that.
- The session link points at `connections.md#sessions-recommended`; if
the Connections ticket renames that heading, this link needs the new
slug (the strict build will say so).
- Glossary: no new term needed.

## Merge Danger

**Door:** two-way. Docs and example scripts only.

**Blast Radius:** docs. `guide/relationships.md` still embeds
`populated_relations.py:basic`, which now prints `Weekly shop |
Checking` style lines from the ledger instead of `10 a1`; #712 rewrites
that page.

## Exit steps

- [x] Issue status updated: progress comment on #709 set to `pr-open`;
`Closes #709` above.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ned (#713) (#734)

Closes #713

## Summary

The schema pages quoted generated SQL that nobody checked, and three
blocks had gone stale. Now every quoted step file is regenerated from
the page's own example models and compared byte for byte.

```diff
 tests/test_docs_migrations.py
   <!-- output: id -->            CLI output        (pinned already)
+  -- NNNN_<name>/<file>          SQL step file     regenerated by the page's example
+  # migrations/NNNN_<name>/x.py  generated data step
+  <!-- revision: id -->          Alembic upgrade()/downgrade(), autogenerated on SQLite
```

What the pin caught, for example the migrations guide's `0001` (model
has `email: str = Field(unique=True)`):

```diff
-CREATE TABLE IF NOT EXISTS "author" ( "id" serial PRIMARY KEY NOT NULL, "name" varchar NOT NULL );
+CREATE TABLE IF NOT EXISTS "author" ( "email" varchar NOT NULL, "id" serial PRIMARY KEY NOT NULL, "name" varchar NOT NULL );
+
+CREATE UNIQUE INDEX IF NOT EXISTS "uq_author_email" ON "author" ("email");
```

Also stale and now fixed: the SQLite rebuild (missing the
`sqlite_sequence` copy and the recreated `idx_author_name`), the
table-rename block (missing the `DO $$` that renames the serial
sequence), the enum-label contract (the type is `status_new`, not
`orderstatus_new`, and SQLite's contract is a rebuild, not
`not-applicable`), and the rename-hint claim (auto-migrate honours a
table hint under `migrate_updates`; without it the pass holds the table
back with a warning).

Page by page:

- **Overview:** "Where the old page went" removed (the #701 redirects
replace it); the Alembic column says SQLite refuses a required-column
add.
- **Auto-migrate:** one running example (`Author` + `nickname`, `dev.db`
from before the change). Four blocks run under a new `auto_migrate`
prelude, including the `PassReport` asserting what the pass ran. Field
and kind lists link to the API reference, and the full backend matrix
moves into a collapsed note under the six common rows.
- **Data steps:** contracts and generated backfill/guard files pinned.
The enum-label section shows the real Postgres contract.
- **Deploying:** the 0.22 and 0.24→0.25 material is now two notes, with
the `grep` commands that find what a 0.24 checkout refuses; status exit
codes link to the CLI reference.
- **Testing:** the example runs the page's `conftest.py` fixture, tests
and `asyncio_mode = "auto"` under real pytest. This caught a bug: the
printed fixture never closed its connection, so the second test failed.
The irreversible round trip is now an asserted `RoundTripResult`.
- **Alembic:** shows the revision `Author.nickname` produces and a
destructive marker, both pinned. "Exactly what the pass does" no longer
contradicts the next paragraph.
- **Adopting:** one name (`0001_create_author`) throughout, staging runs
with `--url` against `staging.db`, drift in code returns a
`BaselineReport`.
- All pages: sentence-case headings (old anchors kept with `{#id}`), no
ADR numbers or dated notes, lead-ins and result sentences, and
compile-only fences say why (`{test=compile reason="..."}`).

## Evidence

- **Before:** `pytest tests/test_docs_migrations.py -k step_file` → `5
failed, 4 passed`, e.g. `guide/schema/migrations.md:112 quotes
0001_create_author/01_schema.up.postgres.sql, which
migrations_quickstart writes otherwise`
**After:** `179 passed, 1 skipped` (the skip is the existing
Postgres-only `ddl-lock-retry`)
- Editing one statement of a quoted file fails naming the page and
block: `guide/schema/data-steps.md:58 quotes
0002_author_slug/04_contract.up.postgres.sql, which migrations_backfill
writes otherwise`.

## Verify

Ran the ticket's Verify block on `84e8ac0`: passed (strict build clean;
`test_docs_examples` 229 passed; `test_docs_migrations` 179 passed, 1
skipped; `_docs_checks` exit 0). Full suite: 3366 passed.
TDD: RED `pytest tests/test_docs_migrations.py -k step_file` failed as
expected (stale blocks); GREEN the same, 18/18 after the fixes.
Verify block corrected: no

## Interfaces

Interfaces changed: none. Produces: step-file pinning in
`tests/test_docs_migrations.py` (`STEP_FILE_SOURCES`, keyed by `(page,
migration)` or `(page, migration, file)`; a value is an example name, a
scenario, or `None` for a data step written by hand). #714 updates the
`guide/schema/migrations.md` keys when it splits the page.

## Notes for the reviewer

**Outside Files owned:**
- `docs/examples/_preludes/auto_migrate.py` (new): the prelude that lets
auto-migrate's blocks run.
- `tests/test_step_class.py`: one line, so it still finds the
rolling-deploy recipe now that its fence carries settings.
- `docs/solutions/patterns/docs-page-conventions.md`: records the
step-file pinning and the compile-only reason convention (I-5).
- `guide/schema/migrations.md`: one link moved off the removed upgrade
anchor.

**Audit rows left, with reasons:**
- Auto-migrate's 29-row matrix (M) has no Reference home yet. #714
creates `reference/migrations.md`, which could take it; for now it sits
in a collapsed note under a six-row summary.
- Deploying's expand/contract table (M) also has no Reference home; it
stays.
- Deploying's Python exception texts (L): `DatabaseAheadError` is
already pinned by `tests/test_step_class.py`, so that audit row was
partly wrong. `PendingMigrationsError` stays unpinned.
- The 0.24→0.25 note should move to the upgrade guide when #707 writes
its 0.25 section.
- `migrations_adopting.py`'s `[start:models]` marker (L) is not unused:
`_migrations_project` reads that region. Kept.
- Annotated twins outside my files (`quickstart`, `rename`, `retire`,
`retire_label`, `squash`) still use `FerroField`; they are #714's.
- GitHub `main` links to example scripts (L) are left as they are; the
same links appear across #714's pages.

**Glossary terms:** none new.

- [ ] Exit step: issue #713 status updated (progress comment at
`pr-open`, closes on merge)

## Merge danger

**Door:** two-way

**Blast radius:** docs. Pages and docs tests only. Old anchors are kept,
and the strict build checks every link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…mples (#719) (#735)

Closes #719

## What

The core API docstrings (`Model`, `Query`, `ProjectedQuery`, `Relation`,
`FerroField`, `ForeignKey`, the query proxies, `Row`/`Rows`, `now`,
`transaction`, `evict_instance`) are now short Google-style reference
entries. Before, the `FerroField` page showed a literal `Attributes:`
paragraph full of `:class:` text, because griffe dropped the section
over a blank line. Now every parameter renders in an `Args:` table,
links are Markdown autorefs, and each main entry point has an example
that runs against the `reference` prelude:

```python
by_views = Post.select().order_by(lambda post: post.views, "desc").order_by(lambda post: post.id).limit(1)
page = await by_views.all()
next_page = await by_views.after(page[-1]).all()
assert [post.views for post in next_page] == [952]
```

That is the new `after()` example. 85 docstring fences now run under
`tests/test_docstring_examples.py`, where none ran before.

## Verify

Ran the ticket's Verify block on `3e21b32`: passed (strict build: no
issues; 85 docstring examples pass; `_docs_checks` clean; `ruff --select
D417` clean; `test_docs_site.py` 13 passed). Full suite: `pytest -n 8
--no-cov` gave 3421 passed. Its one failure
(`test_relation_query_methods_inherit_their_docstrings`) is fixed in
`3e21b32`, and the docs tests were rerun after that fix.
TDD: RED `pytest tests/test_docstring_examples.py -k related` failed
with `AttributeError: 'NoneType' object has no attribute 'all'` (block
compiled with the runner's postponed annotations). An earlier RED for
the same test failed with `SyntaxError` from pydantic's source lookup.
GREEN: the same command passed after the harness fixes. A scratch griffe
audit of the owned modules went from 395 findings to 0: missing
docstrings, wrapped summaries, Sphinx roles, ADR/issue refs, `Args:`
gaps, griffe-skipped sections.
Verify block corrected: no

## Interfaces

Interfaces changed: none. `FieldProxy`, `QueryNode`, `RelationProxy` and
`ValueExpr` have a docstring on every public member, for #722. A trial
render of them, plus `ReverseRelationProxy` and `ExistsTest`, on the
queries page built under `--strict` with no unresolved autorefs.

## Outside Files owned

- `docs/examples/_preludes/__init__.py`: a block that declared a model
could not run. It ran as `__main__`, so pydantic's
`use_attribute_docstrings` read the runner's source and raised
`SyntaxError`. It was also compiled with the runner's `from __future__
import annotations`, so a declared `ForeignKey` was never seen and
became a `JSON` column. The block now runs as its own module whose
`__file__` is the docstring, compiled with `dont_inherit=True`.
- `tests/test_docstring_examples.py`: one test, a block that declares a
related model pair and reads the relation.
- `docs/solutions/issues/docs-block-declaring-a-model.md` (I-5).

## Notes for the reviewer

Found while checking facts (not fixed here, since this PR changes
docstrings only):

- **Silent FK loss under postponed annotations.** A model module with
`from __future__ import annotations` and `author: Annotated[Author,
ForeignKey(...)]` gets `"author" JSON NOT NULL` and no error. This
breaks I-6 (fail loudly). It probably deserves its own issue.
- **`offset()` without `limit()` fails on SQLite**: `near "OFFSET":
syntax error`. The `offset` example uses `.limit(10).offset(1)`.
- **Audit row correction**: `add`/`remove` do *not* accept any primary
key name. They read `instance.id`, and the docstrings now say so.
- **Audit row correction**: `count()` ignores `limit`/`offset`, and
`update()`/`delete()` also refuse `after()`/`before()`. The docstrings
say both.
- `Query.model_cls`/`where_clause`/`order_by_clause` stay public;
renaming them would change behaviour, so each got a one-line docstring
instead.
- `ModelConnection` is documented, but no API page renders it. Rendering
it belongs to #722.
- `ProjectedQuery.count`/`exists` have no return annotation, so the
types are given in `Returns:` as `(int)`/`(bool)`.
- Docstring examples avoid `Field()` (I-7 twins are not possible in a
docstring). Model declarations use `FerroField` inside `Annotated`,
which has one form, and the `ForeignKey` entry shows only the relation
lines of the reference models.
- Private helpers keep their technical docstrings; only their Sphinx
roles were turned into backticks.

- [ ] Exit step: #719 status updated (closed by the orchestrator after
merge to `feat/docs-rewrite`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… type table (#708) (#738)

Closes #708

## Summary

The Models and fields guide now follows one product catalog, and its
field type table is generated by a test, so it can't drift from the
schema again.

```diff
 docs/
 ├── includes/
+│   └── field-types.md           # generated: annotation → SQLite / PostgreSQL column, db_type token table
 ├── examples/
+│   ├── models_and_fields.py     # Product (+ Category, Order, Review); every SQL block on the page is checked against create_tables()
+│   ├── models_and_fields_annotated.py   # same models; asserts the same statements as the assignment twin
 │   ├── table_checks.py          # Pair → Payment (card token or bank account, never both), DDL checked
 │   └── table_checks_annotated.py
 └── pages/guide/models-and-fields.md   # rewritten
 tests/
+└── test_docs_field_type_table.py      # `python -m tests.test_docs_field_type_table` regenerates the include
```

Here are the old table's rows next to what Ferro actually creates (now
generated):

| Annotation | Old page (SQLite / PG) | Generated (SQLite / PG) |
| --- | --- | --- |
| `str` | `TEXT` / `TEXT` | `varchar` / `varchar` |
| `int` | `INTEGER` / `INTEGER`/`BIGINT` | `integer` / `integer` |
| `bool` | `BOOLEAN (0/1)` / `BOOLEAN` | `integer` / `bool` |
| `datetime` | `TEXT (ISO 8601)` / `TIMESTAMP`/`TIMESTAMPTZ` |
`DATETIME` / `timestamp with time zone` |
| `UUID` | `TEXT (36 chars)` / `UUID` | `CHAR(32)` / `uuid` |
| `StrEnum` | `TEXT` / native enum | `varchar(7)` (longest label) /
native enum type `status` |
| `dict` | `TEXT (JSON)` / `JSONB` | `JSON` / `jsonb` |

Each cell is the column definition the create pass writes. The test
builds the column IR, takes the type from `_core._resolve_storage_type`
(a native enum cell is named by it) and renders it with the create pass.
A second table lists every `db_type` token.

The page sections, in order: the first example (the whole program,
session included) · declaration styles · field types (+ `db_type`,
enums, JSON) · primary keys (auto, natural, UUID) · uniques and indexes
(single, composite, composite index, table checks) · custom table names
· validation · **fields on a model base class**
(`#fields-on-a-model-base-class`, for #716) · field options · see also.

## Evidence

- **Before:** `pytest tests/test_docs_field_type_table.py` failed with
`FileNotFoundError` because the include did not exist yet. After a hand
edit of one cell (`str` → `TEXT`), it fails with
`docs/includes/field-types.md is out of date; regenerate it with python
-m tests.test_docs_field_type_table`.
  **After:** `2 passed`.
- Ran the ticket's Verify block on `0810900`: passed (strict build "No
issues found"; `test_docs_examples.py` 217 passed; type-table test 2
passed; `_docs_checks` exit 0). The full suite passed: 3338 passed, 339
skipped, 2 xfailed.
- TDD: RED `pytest tests/test_docs_field_type_table.py` failed as
expected (include missing); GREEN passed (2/2).
- Test scenarios: changing the validation example's price to a valid one
makes `models_and_fields.py` fail with `AssertionError: expected
ValidationError`. Renaming a `where()` lambda parameter to `t` makes
`_docs_checks` exit 1 with `models_and_fields.py:146: lambda passed to
where() takes one-letter parameter 't'`.
- Verify block corrected: no

## Interfaces

Interfaces changed: none. The page produces
`#fields-on-a-model-base-class`, and `python -m
tests.test_docs_field_type_table` regenerates the include.

## Notes for the reviewer

- **The base-class hazard.** The old warning said base-class fields
"silently produce broken subclass defaults". That did not reproduce:
`Review(Timestamped)` gets `created_at` and its default works. What does
happen is that `Timestamped` becomes a model with its own table (`CREATE
TABLE "timestamped" ("created_at" DATETIME NOT NULL)`, no primary key).
The section shows that DDL and the zero-row count. The timestamps how-to
still says the fields "are not contributed to its subclasses' tables",
which is wrong; that page belongs to #716.
- **A bug found and filed: #737.** A nested Pydantic model field
(`dimensions: Dimensions`) hydrates as a `dict`, and inside a session an
identity-map refresh turns the caller's instance's field into a `dict`
too. For that reason the running example uses `dict[str, str]` and
`list[str]`, and the page does not claim that nested models round-trip.
- **SQL comes from the run, not typed by hand.** Each SQL block is a
snippet section of a `DDL` string in the example script. `main()`
compares it, whitespace-normalised, with the `PassReport` statements,
and the Annotated twin compares its own report with the same string
(read through `ast`). The SQL shown is SQLite's, because the run has no
Postgres. The Postgres types come from the generated table.
- **The `Literal` row.** `Literal["S", "M", "L"]` is a native enum on
PostgreSQL whose type is named after the *column* (`size`), not the
annotation. The table reports this because it is what the storage
decision returns. It may be worth a look.
- **Audit rows.** All the High rows are fixed. Medium: no running
example, no DDL beside features, claims without code, no reference
links, and the tab labels are all fixed. Low: headings, bold run-ins,
the slogan, "ferro derives", and the table-check scaffolding are fixed
(the violation now asserts the constraint name inside the shown range,
and the `else` sits outside it). The `# (1)!` row no longer applies,
because the commented JSONB lines are gone.
- **Beyond the audit.** The audit's cells were right. The generated
table also corrects cells the audit did not list: `bool` on PostgreSQL
renders as `bool`, `Decimal` on PostgreSQL as `decimal` (not `NUMERIC`),
and the integer primary key as `serial PRIMARY KEY`.
- **Field options table kept, shortened.** `Field`'s docstring documents
neither `db_type` nor `db_check`, so the table stays as a pointer table,
with each row linking its section, and ends with links to `Field`,
`FerroField` and `DbType`.
- **Release-bound note.** The pre-JSONB upgrade warning became a
self-contained "Changed in 0.15" note at the end of the JSON section,
linking to the upgrade guide.
- **Outside Files owned:** none. `docs/pages/api/exceptions.md` still
mentions `ck_pair_at_most_one_side` as an example name; it embeds
nothing from `table_checks.py`.
- **Glossary terms:** none new.

## Merge Danger

**Door:** two-way

**Blast Radius:** docs

The anchors other pages link to are kept: `#composite-indexes`,
`#table-checks` and `#json-storage-json-and-jsonb`.
`#composite-constraints` is gone, and nothing linked to it.

- [ ] Exit step: issue status updated (#708 closed by the orchestrator
after the merge into `feat/docs-rewrite`; the progress comment is
current).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…several-databases pages (#711) (#736)

Closes #711

## Summary

A reader who copied a block from these pages got `RuntimeError: No
database route`, because no page showed the session the block ran in.
Now every page's first block opens the session, and later blocks link to
one explanation of it, Connections › Sessions (`#sessions`):

```python
await connect("sqlite::memory:", auto_migrate=True)

async with engines.session():
    alice = await Customer.create(email="alice@example.com", name="Alice")
    assert (alice.id, alice.plan) == (1, "free")
```

```diff
 guide/connections.md      Connecting → Sessions (moved up, "recommended" dropped) → options → named connections
 guide/mutations.md        one Customer model (tabs), create → save → batch → delete → upsert → get_or_create → refresh → errors
 guide/transactions.md     one Account model (tabs); rollback warning: the row rolls back, the instance does not
 howto/multiple-databases  "How to use several databases in one app"; the guide links here instead of embedding the same snippets
+docs/examples/{mutations,transactions,multiple_databases}_annotated.py   I-7 twins
+docs/examples/_preludes/connections.py                                   runs the Connections guide's inline blocks
```

The rollback warning, with the result inside the embedded range:

```python
except RuntimeError:
    ...  # the block raised, so everything in it rolled back

assert checking.balance == -925  # the Python object kept the change
await checking.refresh()
assert checking.balance == 75  # the row did not
```

## Evidence

- **Before:** `python -m tests._docs_checks <owned files>` exited 1:
  ```text
docs/pages/guide/connections.md:112: lambda passed to where() takes
one-letter parameter 't'; ...
docs/pages/guide/connections.md:123: lambda passed to where() takes
one-letter parameter 't'; ...
docs/pages/howto/multiple-databases.md:33: lambda passed to where()
takes one-letter parameter 't'; ...
docs/examples/mutations.py:136: try/except asserts on the error but has
no else that raises, ...
  ```
  **After:** it exits 0.
- Ran the ticket's Verify block on `d37abde` before the rebase; see the
fix report for `db31fa4`: strict build `No issues found`;
`tests/test_docs_examples.py` 234 passed; `tests._docs_checks` exit 0.
The full suite passed too (3353 passed, 339 skipped, 2 xfailed).
- Test scenarios: removing the duplicate email from
`mutations.py:create-strict` makes the script fail with `AssertionError:
expected UniqueViolationError`. Renaming one query lambda to `t` makes
`_docs_checks` exit 1, naming the file and line.
- The Connections guide's inline blocks run under `{test=run
prelude=connections}`. The outputs on the page (`1`, `True`, the
`RuntimeError` text, `['Ada Lovelace', 'Grace Hopper']`, `1 0`, the
`CREATE TABLE` statements) come from those runs.

Verify block corrected: no

## Interfaces

Interfaces changed: none. The Connections guide has a `## Sessions`
heading whose anchor is exactly `#sessions`.

## Notes for the reviewer

**Outside Files owned:**
- `docs/examples/mutations_annotated.py`, `transactions_annotated.py`,
`multiple_databases_annotated.py`: I-7 twins for the models each page
now shows (the audit asked for the first).
- `docs/examples/_preludes/connections.py`: the quickstart's models with
no connection, because each Connections block connects for itself.
-
`docs/pages/{index,why-ferro,howto/testing,getting-started/quickstart}.md`:
one-line link change from `connections.md#sessions-recommended` to
`#sessions`. The strict build checks anchors, so the rename would have
failed it otherwise.

**Audit findings answered rather than fixed:**
- *SQL beside each call (mutations M, rule 13):* not added. The docs
conventions say the SQL must come from the example's own run once #700
ships a public way to read a query's SQL, and #725 applies it. Typing it
by hand is what the conventions forbid. The one exception:
`create_tables()` already returns the statements it ran, so the
Connections page prints them.
- *`self.session.__aenter__()` (connections L):* kept, now run under the
harness. There is still no public `open()`, so this goes to the API
owners.
- *"Don't run schema creation concurrently through names on the same
database" (multiple-databases M):* dropped. Auto-migrate and
`create_tables()` take the per-database run lock, so two such passes run
one after the other; the page says so and links to "Two processes at
once". I did not test two names against one physical file.
- *Transactions page embedding `multiple_databases.py:transaction` (M):*
the snippet now uses an unqualified call plus the `ValueError` check. It
lives on the how-to, and the transactions page links there so that it
keeps one running example (`Account`).

**Found while verifying:** a rollback clears the session's identity map.
After a rolled-back block, `Account.get(id)` returns a new instance, not
the stale one you hold. The rollback warning says this.

No new glossary terms.

- [ ] Exit steps: #711 status updated (progress comment at `pr-open`;
the orchestrator closes it after the merge into `feat/docs-rewrite`)

## Fix report (review round 1)

Rebased onto `origin/feat/docs-rewrite` at `b8dbf12`. The new head is
`db31fa4`, which adds commit `db31fa4 fix(docs): repoint anchors renamed
by the guide rewrite and the schema pages`.

**Critical: 11 broken anchors on the trial merge.** Every link to a
heading this PR renamed now points at the new heading:

| Link target before | After | Where (one link line each) |
| --- | --- | --- |
| `connections.md#sessions-recommended` | `#sessions` |
`concepts/query-typing.md`, `guide/queries.md`, `faq.md` (×2),
`roadmap.md`, `guide/schema/auto-migrate.md` (link line only, since #714
is editing that page) |
| `mutations.md#recipe-updates` | `#expression-updates` | `roadmap.md`,
and the `Query.update` docstring in `src/ferro/query/builder.py` |
| `connections.md#connection-pooling` | `#pool-size` | `roadmap.md` |
| `mutations.md#refreshing-from-the-database` | `#refresh` |
`Model.refresh` docstring, `src/ferro/models.py` |
| `mutations.md#bulk-operations-and-the-identity-map` | `#creating-rows`
| `Model.bulk_create` docstring, `src/ferro/models.py` |
| `connections.md#sessions-recommended` | `#sessions` | a docstring in
`src/ferro/models.py` |

The three docstring links render into the API pages, where the strict
build does not check anchors. They were broken too, and `grep` found
them.

This PR's own links into `guide/schema/auto-migrate.md`, which #734
re-headed:
- `#what-the-pass-did-passreport` now links to
[`PassReport`](../api/connection.md#ferro.PassReport) in the reference,
from `guide/connections.md` and `howto/multiple-databases.md`. The
auto-migrate page no longer has a `PassReport` section of its own.
- `#two-processes-at-once` → `#concurrent-passes-and-the-run-lock`
(`howto/multiple-databases.md`).

To confirm nothing is left, `grep -rn
"sessions-recommended\|recipe-updates\|connection-pooling\|refreshing-from-the-database\|bulk-operations-and\|what-the-pass-did\|two-processes-at-once"
docs/pages src README.md` finds nothing.

**Minor findings, all fixed:**
- The `Changed in 0.13` note is now the last block of Sessions; the
reference pointer sits above it.
- Mutations no longer mentions `author=alice`; the sentence links to
Relationships.
- The how-to's `Model.using` method list is gone; the reference link
replaces it.

**Outside Files owned in this round:** the six pages and two source
files in the table above, one link line each.

**Verify on `db31fa4`** (the pushed `HEAD`):

```text
$ uv run --no-sync zensical build --clean --strict
No issues found
$ uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
228 passed
$ uv run --no-sync python -m tests._docs_checks <the seven owned files>
exit 0
$ uv run --no-sync pytest tests/test_docs_site.py tests/test_docstring_examples.py tests/test_docs_checks.py tests/test_documentation_features.py -q --no-cov -p no:cacheprovider -n 8
146 passed, 4 skipped
```

The strict build is the test for this round: before the fix it aborted
with `11 issues found`, and now it reports none.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ides rewrite

#738 and #736 merged in parallel: models-and-fields linked
connections#sessions-recommended and mutations#recipe-updates, which #736
renamed to #sessions and #expression-updates. The strict build failed on the
integration branch.

Part of #698.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce (#715) (#733)

Closes #715

## What

The 736-line RLS guide becomes three pages. A reader who wants "scope my
invoices by tenant" now sees, on the first screen:

```python
async with engines.session("app", settings={"app.tenant_id": str(ACME)}):
    invoices = await Invoice.select().order_by(lambda invoice: invoice.total).all()
    assert [(invoice.customer, invoice.total) for invoice in invoices] == [
        ("Hooli", 120),
        ("Initech", 450),
    ]
```

and, a few blocks down, that the same query in a session without
settings returns `[]`, that the migrating role sees all four rows, and
that a write into another tenant fails with SQLSTATE `42501`.

- `guide/row-level-security.md` (how-to, retitled "Scope rows by tenant
with Postgres row-level security"): the policy, the application's role,
scoped sessions, `set_config` after open, framework middleware, admin
jobs, deploy order, JSON claims.
- `concepts/row-level-security.md` (explanation): the `NULLIF` contract,
`FORCE`/`BYPASSRLS`, **`#session-settings`** (the one statement of round
trips and `set_config` visibility, for #720), the pooler constraint,
connection delivery, drift and ownership.
- `reference/row-level-security.md`: `RowSecurity`, `RowPolicy`, policy
names, the command table (from `_core._rls_command_matrix`), the
shorthand's casts (from `row_policy_shorthand_cast`), composing
policies, the session settings API and its validation, SQLite, warnings.

The example script now runs on Postgres: `python
docs/examples/row_level_security.py <url> <role>` migrates as the
connecting role, creates a NOSUPERUSER app role, and asserts every
result the pages show. A new `postgres_only` test runs both twins that
way (it skips without a Postgres provider, and runs in CI's backend
matrix job). Without arguments the script still runs on SQLite.

## Verify

Ran the ticket's Verify block on `140abd6`: passed (strict build: no
issues; `tests/test_docs_examples.py`: 230 passed, 2 skipped;
`tests._docs_checks`: exit 0). Full suite: 3349 passed, 341 skipped, 2
xfailed.
TDD: RED `python -m tests._docs_checks <copy of the example with lambda
t: t.total>` exited 1 naming `row_level_security.py:126` and `:162`; RED
`python <copy of the example whose SQLite session drops settings=>`
failed with `AssertionError: settings= on SQLite must be refused`; GREEN
the Verify block above.
Verify block corrected: no

Postgres: no server is available on this machine (libpq only, no
`postgres` binary, Docker daemon off), so the Postgres path did not run
locally. It ran in CI:
`test_row_level_security_example_runs_on_postgres` PASSED for both twins
in the "Python backend matrix (SQLite + Postgres)" job (run 39296785119,
on `cbf5a68`; `140abd6` only applies ruff format to the two scripts).

## Interfaces

Interfaces changed: none.
`concepts/row-level-security.md#session-settings` exists as the ticket's
Produces says. The new pages are not in the nav (#723).

## Facts checked against the code

- **Round trips (audit M, lines 208/595).** Three is right. Outside a
`transaction()`, an operation in a settings-bearing session sends
`BEGIN` (`backend.rs` `begin_transaction_connection_with`, its own
awaited `execute`), then `SELECT set_config(...)`
(`apply_session_settings`), then the operation, then `COMMIT`. The
`engines.session` and `PoolConfig` docstrings' "about two" is the
outlier; #720 owns those docstrings.
- **`set_config` visibility (audit M, lines 291–297).** The old page was
right and the `Session.set_config` docstring is too narrow.
`reapply_settings_to_open_transactions` re-applies to every entry in the
session's `transaction_registry`, and `begin_transaction` registers
every explicit `transaction()` there whichever task opened it. So an
open `transaction()` in *any* task sharing the session sees the new
value from its next statement. Self-wrapped operations
(`OperationScope`) are never registered, so they finish with the old
value. The concepts page states this once under `#session-settings`, for
#720 to align the docstring to.
- **`membership` (audit H).** Declaring a `Membership` model next to the
policy does not fix the example. Ferro orders tables for creation by
foreign keys only (`order_models_for_create`), and Postgres resolves a
policy's tables at `CREATE POLICY`. With `Membership` FK'd to
`Document`, `ferro migrate new` writes `document`'s `CREATE POLICY ...
FROM membership` before `CREATE TABLE "membership"`, which fails on
Postgres. The composed example now reads only its own table (owner
policy plus `published_read`), and the reference has a Note on policies
that read another table. **This is a product gap worth its own issue:**
a raw policy that reads a table created in the same pass fails mid-pass.
- **JSON-claims function.** The old `auth_uid()` returned `->> 'sub'`
(text) from a function declared `RETURNS uuid`. Postgres has no
assignment cast from text to uuid, so that function fails to create. The
page now casts explicitly.
- **FastAPI and Litestar recipes.** I checked both with a scratch app
(FastAPI + TestClient; Litestar with both middleware styles, with a
contextvar standing in for the Ferro session). A contextvar set in an
app-level `yield` dependency reaches both async and sync handlers. Both
Litestar classes scope `/read` and leave the opted-out `/health`
unscoped.

## Audit rows answered rather than fixed

- **M 403–570, recipes untested.** They stay compile-only, each fence
marked `{compile-only="..."}` with the reason (Litestar/FastAPI are not
dev dependencies, and the calls need Postgres). I checked them by hand
as described above, and the page now states where each recipe expects
authentication to put the user.
- **L 692–701, `<!-- output: -->` pin for the migrations listing.** The
listing is regenerated for `Invoice` (`0002_invoice_rls`,
`rls_invoice_tenant_id`), copied from a real `ferro migrate new` run. It
is not pinned: the `<!-- output: -->` mechanism compares CLI stdout, not
step-file contents, and its page list lives in
`tests/test_docs_migrations.py`, which this ticket does not own.
- **L 33, `Doc` → `Document`.** Done.
- **L 681, bare "(ADR-0019)".** Removed.

## Notes for the reviewer

- **Outside Files owned:** `tests/test_docs_examples.py`, which gets one
new test, `test_row_level_security_example_runs_on_postgres`. The
ticket's Postgres scenario needs a Postgres-gated test, and the docs
harness has no other Postgres hook.
- One running example per page is `Invoice`. The reference's "Composing
policies" uses a second model, `Document`, because three policies on one
table need columns `Invoice` lacks.
- "The first block shows the wrapper": all three pages open with the
scoped-query block, which has `async with engines.session(...)`.
- Signature blocks in the reference are written as valid Python stubs
(`class RowSecurity: def __init__(...)`, the `RowPolicy` dataclass
fields), so the harness can compile them.
- Glossary: no new term needed; RLS, GUC and ASGI are already defined,
and `abbreviations.md` is untouched.

## Exit steps

- [ ] Issue status updated: #715 closes via this PR (the orchestrator
closes it after the merge into `feat/docs-rewrite`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#736 and #733 merged in parallel: the Connections guide linked
row-level-security.md#session-settings and #connection-delivery-mode, which
#733 moved to concepts/row-level-security.md (#session-settings,
#connection-delivery). The strict build failed on the integration branch.

Part of #698.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng example each (#712) (#739)

Closes #712

## What

A reader who copied the Raw SQL page's placeholder example got `no such
table: users`: Ferro names a table after its model in lowercase, so the
table is `event`, not `events`. Every raw SQL block now runs against the
`event` table the page declares, and the first block shows the session
it needs. Outside a session, a raw call raises `RuntimeError: No
database route for this operation`.

The Relationships page used nine model sets. It now uses one roster
(Team, Player, Profile, Tournament) and shows the tables Ferro creates
for it, printed from the `create_tables()` report. For example, the
many-to-many join table:

```sql
CREATE TABLE IF NOT EXISTS "player_tournaments" (
  "player_id" integer NOT NULL,
  "tournament_id" integer NOT NULL,
  CONSTRAINT "fk_player_tournaments_player_id_player" FOREIGN KEY ("player_id")
    REFERENCES "player" ("id") ON DELETE CASCADE,
  CONSTRAINT "fk_player_tournaments_tournament_id_tournament" FOREIGN KEY ("tournament_id")
    REFERENCES "tournament" ("id") ON DELETE CASCADE
)
CREATE INDEX IF NOT EXISTS "idx_player_tournaments_tournament_id_player_id" ...
CREATE UNIQUE INDEX IF NOT EXISTS "uq_player_tournaments_player_id_tournament_id" ...
```

The example script asserts these statements byte for byte, so the page
cannot drift from what Ferro runs.

## Verify

Ran the ticket's Verify block on `1511130` (rebased onto `a10707f`):
passed (strict build: no issues; `tests/test_docs_examples.py`: 200
passed, 2 skipped; `tests._docs_checks` over the owned files: exit 0).
Full suite: 3355 passed, 339 skipped, 2 xfailed.
TDD: RED `pytest tests/test_docs_examples.py -k raw-sql`, with the
page's original SQLite placeholder block marked `{test=run
prelude=raw_sql}`, failed with `OperationalError: ... no such table:
users`; GREEN, the same command after the rewrite: 6/6 passed.
Test scenarios: renaming a lambda parameter to `t` in a copy of
`relationships.py` makes `tests._docs_checks` exit 1 naming file and
line; replacing the `else: raise` in `raw_sql.py`'s error check makes it
exit 1 too.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Audit findings

**Relationships**
- H, I-8 lambdas: fixed. `populated_relations.py:basic` already used
`lambda transaction` after #709. The page no longer embeds it and
instead has its own `include()` block on Team/Player
(`relationships.py:populated`), which also resolves the
Transaction/Account cast finding.
- M, `NO ACTION`: fixed, verified on SQLite. `NO ACTION` and `RESTRICT`
both refuse the delete with `ForeignKeyViolationError`. I left out the
audit's "deferrable on Postgres": Ferro never emits `DEFERRABLE`, so the
check cannot be deferred.
- M, join table undocumented: fixed. The page shows the `player` DDL
(shadow columns, `fk_*`, `idx_player_team_id`) and the join-table DDL.
On Postgres, only the `id` column differs (`serial PRIMARY KEY`), which
I checked with the core's create-table renderer because no Postgres
server was available.
- M, nine casts: fixed. M, lead-in and result sentences: fixed. M,
roadmap language: fixed. L, "Overview" heading: folded into the intro.
L, Title Case headings: fixed. L, unnamed exception: now `RuntimeError`,
verified.
- L, `profile: "Profile" = BackRef()` annotation: the page now says
awaiting it returns the instance or `None`, verified. The annotation
stays. `Relation["Profile"]` raises `TypeError: Field 'profile' must use
Relation[list[T]]`, so a typed one-to-one reverse needs a library
change. That belongs to the type-safety work, not this page.
- M, CASCADE never exercised: fixed. The `on-delete` block deletes a
team and asserts CASCADE (players, profile) and SET NULL (tournament
winner).
- M, `remove()` then `clear()` with no result between: fixed. L, `crab`:
fixed. L, `"Employee"` without `None`: kept on purpose, because `"Player
| None"` fails at `connect()` (verified) and the page says so.

**Raw SQL**
- H, plural tables: fixed. H, `RuntimeError` outside a session: fixed
and asserted (`raw_sql.py:no-session`).
- M, `autocommit=True` and `session=`: added under "Statements that
refuse a transaction" and "Routing", with the RLS connection-delivery
caveat linked.
- M, heading: now "Use cases". M, results: shown. L, "ORM territory
now": fixed. L, Warning: now a Note.
- `raw_sql.py`: asserts moved inside the ranges.

## Notes for the reviewer

- **Outside Files owned:** `docs/examples/raw_sql_annotated.py` is the
I-7 twin. The page now shows the `Event` model, and I-7 requires both
tabs to be backed by a runnable script.
`docs/examples/_preludes/raw_sql.py` is the prelude that the page's
`{test=run prelude=raw_sql}` SQLite placeholder block runs under.
- Postgres-only blocks (the `$1` placeholder, the `::timestamptz` cast,
`autocommit=True`) are marked `{test=compile reason="needs a Postgres
database"}`. The cast example's error text, `operator does not exist:
timestamp with time zone >= text`, comes from
`tests/test_query_joins.py`.
- **Library footguns found (not fixed here, worth issues):**
1. `Annotated[Team, ForeignKey(...)] | None` connects silently, with no
foreign key, no `*_id` column and no error. The page warns about it, but
under I-6 Ferro should refuse it.
2. `spring.winner = rustaceans` raises Pydantic's `AttributeError:
'winner' is a ClassVar`, so the page sets `winner_id`.
3. `add()`/`remove()` read `instance.id` (#719 limit). The page states
the limit.
- `execute()`'s docstring says "Outside any transaction, runs on a
one-off pool connection". That is true but reads as if no session were
needed. Docstrings are out of scope here.
- No new glossary terms are needed.

## Fix report, round 1

Rebased onto `origin/feat/docs-rewrite` at `a10707f`. The fix commit is
`1511130`.

- **Spec, dead anchors:** `connections.md#sessions-recommended` is now
`connections.md#sessions` (raw-sql.md:31, relationships.md:17).
`row-level-security.md#connection-delivery-mode` is now
`../concepts/row-level-security.md#connection-delivery` (raw-sql.md:72),
where the `autocommit=True` caveat now lives. I also checked every other
cross-page anchor on both pages against the rebased tree:
`connections.md#named-connections`,
`queries.md#querying-across-relationships`,
`queries.md#populating-relations-with-include` and
`models-and-fields.md#composite-indexes` all still exist.
- **Quality, compile-only marker:** the three Postgres-only blocks now
use `{test=compile reason="needs a Postgres database"}`.

Covering tests: the strict build, which fails on a missing anchor, and
`tests/test_docs_examples.py`, which compiles every block.

```
$ uv run --no-sync zensical build --clean --strict
No issues found
$ uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
200 passed, 2 skipped in 105.57s
$ uv run --no-sync python -m tests._docs_checks <owned files>
exit=0
```

## Exit steps

- [ ] Issue status updated: #712 closes through this PR when the
orchestrator merges `feat/docs-rewrite` work (`Closes #712` above); the
progress comment is current.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #710

## What

The aggregations guide now uses a four-row ledger with distinct totals
(Checking 30, Savings 45, orphan 40), so the grouped, top-N and
descending examples show a real effect: top-N returns
`[{"account_label": "Savings", "total": 45}]` and the lambda sort
returns `[(2, 45), (1, 30)]`.

- The wrong "one of four has a price" comment is gone; the comment now
says three of four, matching the assert.
- Each error check has `else: raise AssertionError(...)`, and each limit
has its own snippet and shows its message.
- All 24 one-letter lambdas are named `transaction`.
- The inline partition block runs under a new `aggregations` prelude
(`docs/examples/_preludes/aggregations.py`).
- Headings are sentence case; ADR and issue references and "pinned" are
removed; the page links to the API reference.
- `price: Decimal | None = None` in both twins; Annotated twin follows
the one idiom.

## Verify

Ran the ticket's Verify block on `ed1ed45`: passed (strict build, 233
docs example tests, `_docs_checks` exit 0). Full suite: 3370 passed.
TDD: RED `python -m tests._docs_checks docs/examples/aggregations.py`
with one `else` removed exited 1 naming the line; GREEN the same command
exited 0 with it restored.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- Outside Files owned: new `docs/examples/_preludes/aggregations.py`
(the harness needs a prelude for the inline block).
- Audit row left: "show the generated `GROUP BY` SQL". The brief says no
hand-written SQL until captured SQL lands (#725), so none is added.
- Audit finding found wrong in spirit: the builtin-`sum` error text
hardcodes `t.` (`did you mean t.amount.sum()?`) whatever the lambda
parameter is named. The page says so; fixing the message is a source
change outside this ticket.
- The `!= None # noqa: E711` comments stay: there is no per-file ruff
ignore config to move them to.
- `docs/pages/api/queries.md` still links the old page title and cites
`#291`; not owned here.
- No new glossary terms needed.

- [ ] Issue status updates complete before merge (I-9)

🤖 Generated with [Claude Code](https://claude.com/claude-code)


## Fix round 1

- Both Sessions links now point at `connections.md#sessions` (the
heading was renamed on the base). Rebased onto current
`origin/feat/docs-rewrite`.
- The error messages shown on the page are now asserted by the example
itself (full text where the page shows all of it, the shown prefix
otherwise), so the page cannot drift from the code. The
unnamed-aggregate message carries an issue number in the source; the
page elides it.
- Covering tests: `tests/test_docs_examples.py` (aggregations.py, the
inline block) and `tests/test_docs_site.py`.
- Command: `uv run --no-sync zensical build --clean --strict` printed
"No issues found"; `pytest tests/test_docs_examples.py
tests/test_docs_site.py` gave 213 passed, 2 skipped; `_docs_checks` exit
0. Verify SHA `3813312`.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #707

## What

A reader on 0.15 now finds a section, or a row in a no-action table, for
every release up to 0.25. The page reads one model, `Account`, and every
recipe runs.

```python
async with engines.session():
    adults = await Account.where(lambda account: account.age >= 40).order_by("name").all()
```

- New sections for 0.25, 0.24, 0.23, 0.22, 0.21, 0.18, 0.17.1 and
0.16.3, each listing only what the upgrade asks (from `CHANGELOG.md` and
the feature PRs, checked against the code). Every other release from
0.13 is a row in "Releases that need no action".
- Every old recipe uses `ferro.Model` (there is no `ferro.Ferro`),
`lambda account:` parameters, Assignment and Annotated tabs backed by
`docs/examples/upgrade_*.py` pairs, and a result sentence. "After"
blocks run under `{test=run prelude=upgrade}`; "Before" blocks of
removed APIs are `{test=compile reason=...}`.
- Implicit routing is attributed to 0.14 (the 0.13 tag still has
`allow_legacy_default`; 0.14 raises `No database route`).

## Verify

Ran the ticket's Verify block on `1cb2616`: passed (strict build "No
issues found"; `tests/test_docs_examples.py` and
`tests/test_docs_site.py` green; `_docs_checks` exit 0). Full suite:
3381 passed, 339 skipped, 2 xfailed.
TDD: RED `python -m tests._docs_checks <old page>` listed one-letter
lambdas (`t`) at lines 164, 165, 184, 194; GREEN `python -m
tests._docs_checks` over the new page and examples exit 0, and `pytest
tests/test_docs_examples.py -k upgrade` 28 passed.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Audit rows

- H coverage to 0.25, H `Ferro` class, H I-7 twins, H lambdas: fixed.
- M routing release: audit was right (0.14); page, table and runtime
message now agree. M "catches every deprecation": added the
skip-past-0.14 paragraph. M stale "reviewed Alembic migration": now "a
hand-written step in `ferro migrate`, or an Alembic revision". M result
sentences: added.
- L title case, headings, table as range rule, ADR and repo-path links:
fixed. L collapsible prompts: kept.
- Audit leads that were wrong or incomplete:
- `db_type="varchar"` is not a valid token (only `text` and
`varchar(N)`), so the old "keep varchar" recipe never worked. The enum
keep recipe is now `db_type="text"`. A `datetime.time` field accepts no
text `db_type` at all, so the page says there is no keep spelling and
gives the convert recipe.
`docs/solutions/patterns/derived-type-and-naming-decision-table.md`
still says `db_type="varchar"` in its keep-recipe table (not touched
here).
- The audit's "retired enum labels refusing writes" is opt-in, so it is
a "also new" line in 0.23, not an ask.
- Two anchors other pages link to are kept with explicit ids
(`#predicates-operator-style-and-col-are-gone`,
`#connection-routing-requires-a-session`).

## Outside Files owned

- `src/ferro/state.py`: the runtime `No database route` message and a
docstring said "removed in v0.13"; now "v0.14" and "see the upgrade
guide" (the audit asked for the message to match). No test matches the
text.
- `docs/examples/_preludes/upgrade.py`: the prelude for the page's
blocks (the brief allows a new prelude).

## Notes for the reviewer

- `docs/pages/guide/connections.md` still says "removed in `0.13`"
(owned by another ticket).
- Glossary terms the page could hover but I did not add: none new.
- Exit steps: [x] issue #707 status updated through this PR's `Closes`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #717

## What

The SQLAlchemy migration page no longer says Ferro has "no session".
Before, a SQLAlchemy user copying `await User.create(...)` hit
`RuntimeError: No database route`. Now the first block shows `async with
engines.session():`, and the page says what the session is (routing plus
identity map, no `add`/`flush`/`commit`). Every Ferro block is embedded
from `docs/examples/from_sqlalchemy.py` (and an Annotated twin) and
shows its asserted result. Each SQLAlchemy/Ferro pair is a tab pair with
a result sentence. The table names `ferro migrate` with the Alembic
bridge as an option. Lambdas are named after the model, headings are
sentence case, and the page runs on one `User`/`Post` pair.

## Verify

Ran the ticket's Verify block on `c24afa5`: passed (strict build clean;
`tests/test_docs_examples.py` 193 passed, 2 skipped; `_docs_checks` exit
0).
TDD: RED, the old page's Ferro blocks raised `No database route` outside
a session (audit, reproduced as stated); GREEN `pytest
tests/test_docs_examples.py` passed, with both new scripts running end
to end.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- Audit rows all addressed. SQLAlchemy-side blocks are `test=compile`
with a reason (running them needs an async SQLAlchemy engine; left
compile-only deliberately).
- The "Annotated" twin follows the recorded idiom (`Annotated[T,
Field(...)] = default`).
- Removed the `#291` link and "recipe door" jargon from the text.
- No new glossary terms needed.
- Outside Files owned: none.

- [ ] Issue status updates confirmed (I-9): #717 closed by this PR's
merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… reference entries (#721) (#745)

Closes #721

## Summary

Every docstring in the migrations API, the pass report and the CLI is
now a Google-style reference entry: one-line summary, every parameter in
`Args:`, `Returns:` / `Raises:` sections that render as one row per
item, and no Sphinx roles, RST `::` blocks, ADR numbers or issue
numbers.

`up()` before and after, as the API page shows it:

```diff
-Apply every pending step of every pending migration, in order, under
-the run lock (``ferro migrate up``).
-
-Works on ``database`` of ``settings`` over ``using`` ... (:meth:`~ferro.migrations.target.Target.resolve`) ...
-``allow_ahead`` lets a database ... through (ADR-0038) ... (:data:`AllowAhead`).
+Apply every pending migration, in order, under the run lock.
+
+Args:
+    settings: The project's configuration. Defaults to `FerroSettings()`.
+    ...
+    allow_ahead: Run beside a database that has applied migrations the
+        directory lacks: `"expand-only"` ... Defaults to `False`, which refuses.
+    progress: ...
+    on_event: ...
+Returns: ...
+Raises: ...
+Examples: (fenced, {test=compile reason="needs a migrations project and a database"})
```

What changed, by area:

```text
src/ferro/
├── pass_report.py            # a docstring on every PassReport / Report / Subject field, role values listed inline,
│                             # a runnable example (drop "post", create_tables() recreates it)
├── migrations/
│   ├── chunked.py → _chunk_loop.py   # ferro.migrations.chunked now resolves to the decorator, not this private module
│   ├── runner.py / report.py / generate.py   # RunReport, StatusReport, CheckReport and every field documented (for #722)
│   ├── steps.py              # atomic / chunked / irreversible / nothing_to_reverse / todo: Args, Raises, examples
│   ├── errors.py, report.py  # exception attributes documented where they are set
│   ├── testing.py            # the slug-backfill walk-through moved into harness()'s Examples
│   ├── _drift.py             # render_op example runs against the reference prelude
│   └── alembic.py, translate.py, _baseline.py, layout.py, scaffold.py, context.py, target.py, historical.py, rerecord.py
└── cli/
    ├── __init__.py, output.py  # Args on every helper; roles and ADR refs gone
    └── migrate.py               # squash help prints no literal backticks; check's summary is one line;
                                 # `--replace` help puts the condition first; status --steps/--json lose --no-*;
                                 # `ferro --help` reads "Ferro's command line: ... for Ferro models."
```

## Evidence

TDD: `tests/test_cli_help.py` gained two checks over every command: help
carries no Markdown (backticks, bold, links) and its summary is one
capitalised line ending with a period.

- **Before:** `uv run --no-sync pytest tests/test_cli_help.py -q
--no-cov -p no:cacheprovider`: 3 failed, 34 passed (`ferro migrate
squash` help carries Markdown ``['`', '`']``; `ferro` summary starts
lowercase; `ferro migrate check` summary spans two lines).
  **After:** 37 passed.

Docstring audit (a scratch griffe script, not committed: google parser
warnings, multi-line summaries, Sphinx roles, RST blocks, ADR/issue
refs, parameters missing from `Args:` over every public object in the
owned files): 495 problems before, 0 after.

`ruff check --select D417` catches a dropped parameter: removing
`progress` from `up()`'s `Args:` reports `D417 Missing argument
description in the docstring for up: progress`.

## Verify

Ran the ticket's Verify block on `456544e` (rebased on the current
`feat/docs-rewrite`): passed. Strict build "No issues found";
`test_docstring_examples.py` 112 passed; `_docs_checks` exit 0; `ruff
--select D417` all checks passed; `test_docs_site.py` 13 passed;
`test_cli_help.py` 37 passed. Full suite `pytest -n 8 --no-cov`: 3408
passed, 339 skipped, 2 xfailed before the rebase. After it: 3462 passed
and 3 failed, all in `tests/test_docs_migrations.py` on
`guide/row-level-security.md:273` (quoted step files of
`0002_invoice_rls` with no `STEP_FILE_SOURCES` entry). This PR touches
neither file; the failure comes from #715's RLS page split meeting
#713's step-file pin on the integration branch.

Verify block corrected: yes: `src/ferro/migrations/chunked.py` is now
`src/ferro/migrations/_chunk_loop.py` (the audit's own fix for
`ferro.migrations.chunked` resolving to the private module), so the
`_docs_checks` and `ruff --select D417` commands ran with that path.

## Interfaces

Interfaces changed: none. `ferro.migrations.chunked` (the decorator) is
unchanged; only the private module `ferro.migrations.chunked` became
`ferro.migrations._chunk_loop`.

## Notes for the reviewer

- **Outside Files owned:** `tests/test_chunked_steps.py` and
`tests/test_migrate_rerecord.py` import `encode_cursor` /
`decode_cursor` from the renamed private module; one import line each.
- **Not done here:** `ferro.migrate()` and `ferro.create_tables()` live
in `src/ferro/__init__.py`, which #720 owns; their broken `Returns:`
rows are #720's to fix. `docs/pages/api/migrations.md` still renders
`::: ferro.migrations.steps.chunked`; `::: ferro.migrations.chunked` now
works too, for #722 to switch.
- **Links:** `RunReport`, `StatusReport`, `CheckReport`,
`InheritedEvent`, `StepEvent` are in plain code, not autorefs, because
no page renders them yet and the strict build would fail; #722 can turn
them into links once it renders them. Page links in docstrings point at
pages only, no anchors
(`guide/schema/{auto-migrate,deploying,data-steps,testing}.md`,
`guide/raw-sql.md`, `howto/adopting-migrations.md`), all present after
the rebase.
- **CLI commands** keep their parameter help in `Parameter(help=...)`,
which `--help` prints; their docstrings carry no `Args:` (D417 does not
ask for one, and a second copy would drift).
- **Examples:** `PassReport` and `render_op` run against the `reference`
prelude. Examples that need a migrations project or a database are
`{test=compile reason="..."}`, the convention #713 introduced.
- **Audit row corrected:** the H row on `<placeholder>`s in CLI help was
already fixed by #702; this PR covers the leftover literal backticks in
`squash`.
- **Behaviour:** the only CLI behaviour change is that `ferro migrate
status` no longer accepts `--no-steps` / `--no-json` (the audit's L row;
nothing used them).

## Merge Danger

**Door:** two-way.

**Blast Radius:** small. Docstrings and help text, plus one private
module rename; anyone importing `ferro.migrations.chunked` as a module
(not the decorator) would break.

## Exit steps

- [ ] #721 is closed by the orchestrator after this merges into
`feat/docs-rewrite` (`Closes #721` above).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…that run (#716) (#742)

Closes #716

## What

The four short how-tos now teach what Ferro does today, and every block
on them runs:

- **Pagination** pages with `Query.after()` / `.before()` and
`position_of()` instead of a hand-built `where(id > cursor)`. The API
recipe asks for one extra row to know whether a next page exists, and
the FastAPI endpoint opens `engines.session()`, which the old one never
did (it raised `No database route`).
- **Timestamps**: the batch-update recipe was `update(lambda note:
{"updated_at": now})`. On SQLite that stores `2026-10-19 18:18:47` while
`save()` stores `2026-10-19T18:18:47.714809Z`, so the updated row sorts
before every row saved the same day and reads back naive (`TypeError`
against `utcnow()`). The recipe is now `update(...,
updated_at=utcnow())`, which Ferro stores exactly like `save()` (I-13);
`timestamps.py` asserts the stored text has one shape, the rows sort by
time, and the values compare with `utcnow()`. A warning explains the
`now` behaviour on SQLite.
- **Timestamps and soft deletes** both say a `Model` base class gets a
table of its own and link to
`guide/models-and-fields.md#fields-on-a-model-base-class` (the old
"fields are not contributed" claim was false).
- **Testing**: `tests/test_docs_testing_fixtures.py` builds the project
the page describes in a temp directory (`myapp/models.py`,
`tests/conftest.py` from `testing_conftest.py`, the page's
`pyproject.toml` block, and every page fence marked `{test=pytest
path="tests/..."}`) and runs pytest on it. It checks the page's own
tests pass, that `pytest -q tests/test_users.py` prints the count the
page shows, that `db` isolates tests, that `db_transaction` rolls a
created row back, and (Postgres CI) that `pg_db` works. The unique test
expects `UniqueViolationError`.
- Titles: "How to test a Ferro app with pytest", "How to paginate query
results", "How to record created and updated times", "How to soft-delete
rows".

## Verify

Ran the ticket's Verify block on `5e00650` (rebased onto `6f8e7ca`):
passed.
TDD: RED `uv run --no-sync pytest tests/test_docs_testing_fixtures.py -q
--no-cov -p no:cacheprovider` against the original `testing.md` and
`testing_conftest.py` failed as expected (`4 failed, 1 passed, 1
skipped`: the page had no runnable test modules, and `db_transaction`
yielded `None` and kept the row); GREEN the same command on this branch
passed (`5 passed, 1 skipped`, the skip is the Postgres-only test, which
CI's `--db-backends=sqlite,postgres` job runs). Timestamps: a probe on
the base branch confirmed the `now` recipe stores `'2026-10-19
18:18:47'` and hydrates naive; `docs/examples/timestamps.py` now pins
the fix and passes under `tests/test_docs_examples.py`.
Full suite `uv run --no-sync pytest -n 8 --no-cov`: 3414 passed, 342
skipped, 3 failed. The 3 failures are `tests/test_docs_migrations.py`
cases for `guide/row-level-security.md:273` (`0002_invoice_rls`), which
have no `STEP_FILE_SOURCES` entry since the RLS split (#715) landed on
the integration branch; the page and the test are files this branch does
not touch.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

**Outside Files owned** (all new example files, no existing file outside
the list touched):

- `docs/examples/pagination_annotated.py`: the pagination page now
declares its model once at the top, which needs the I-7 Annotated tab.
- `docs/examples/testing_models.py` / `testing_models_annotated.py`: the
audit asked for `User`/`Post` to exist instead of the phantom
`myapp.models`; the fixtures test copies the first to `myapp/models.py`.
- `docs/examples/testing_conftest_postgres.py`: `pg_db` with its own
imports. Keeping it in `testing_conftest.py` needed mid-file imports
with `# noqa: E402` on the page.

**Decisions the ticket left open**

- `db_transaction` now rolls back (raises a private `RollBack` inside
`transaction()`) and yields the transaction handle, so raw SQL in the
test runs on the test's connection. The old fixture committed and
yielded `None`.
- The page's test modules run from the page itself: a fence
`{test=pytest path="tests/test_users.py"}`.
`tests/test_docs_examples.py` compiles them (it only acts on
`test=run`), and the fixtures test runs them.
- The compile-only fence follows the recorded convention, `{test=compile
reason="..."}` (`docs/solutions/patterns/docs-page-conventions.md`). The
FastAPI endpoint is the one compile-only block (FastAPI is not
installed); `article_page()`, which it calls, runs in `pagination.py`.
- Each page's first block shows the session: the model tab includes a
second snippet range with `connect()` and `async with
engines.session():`.
- The soft-delete example makes `number` unique and shows the
`UniqueViolationError` a reused number raises; the "enforce uniqueness
in application code" option is replaced by a sentence saying it races.
The mixin's `active()` names its lambda `row`, and the page says why.
- `from ferro.query import Query` stays: `ferro.query.__all__` exports
`Query`, so it is a public import (the audit row called it internal).

**Audit rows not fixed, with the reason**

- Generated SQL beside each query (pagination M rows): no public way to
read a query's SQL exists yet (#700); #725 adds it to the guides.
- Nav labels in `zensical.toml` still read "Testing", "Pagination",
"Timestamps", "Soft Deletes": the file is outside Files owned and shared
with the sibling tickets. The page titles changed.
- `pg_db` is gated on Postgres and skipped locally (no server here);
CI's Postgres job runs it.

**Follow-ups worth an issue** (not filed)

- `now` in an update recipe on SQLite writes `CURRENT_TIMESTAMP` text,
unlike every other datetime write, so it mis-sorts against `save()`'s
rows and hydrates naive. The page warns about it; the library could
render `now` on SQLite in `save()`'s form.
- `ferro_ddl_lowering::timestamp_tz_conversion_warning` still sends
users to "a reviewed migration (Alembic)"; the page now points at a
hand-written `ferro migrate` step or Alembic.

## Exit steps

- [ ] #716 status is updated (progress comment `pr-open`; the
orchestrator closes it after the merge into `feat/docs-rewrite`).

## Fix report (review round 1)

- Spec, `pagination.md:60`: the page now says the order keys must
*include* the primary key, which is what
`Query._assert_position_order_keys`, the `after()` docstring and the
Queries guide say. "End with" was wrong.
- Quality, `pagination.md:80`: the FastAPI fence is now `{test=compile
reason="..."}`. It was the only compile-only fence in this PR's files.
The note above that called `{compile-only=...}` the merged convention
was wrong and is corrected.
- Rebased onto `origin/feat/docs-rewrite` at `6f8e7ca`. The strict build
reports no issues and no dead anchors. The Verify block passed on
`5e00650`: build clean, `test_docs_examples.py` 200 passed and 2
skipped, `test_docs_testing_fixtures.py` 5 passed and 1 skipped (the
Postgres test), `tests._docs_checks` exit 0.
- The base still fails the 3 RLS tests in
`tests/test_docs_migrations.py`. PR #744 fixes them, and this branch
does not touch those files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…xample (#718) (#746)

Closes #718

## What

The five concept pages (architecture, identity map, type safety,
database backends, performance) now share one running `User` model and
say only what the code does today. Take the architecture page's old
example: `User.where(lambda t: t.active)` raised `TypeError: where()
predicate callable must return QueryNode`. The page now opens with the
model and a query that runs, with its result shown:

```python
async with engines.session():
    users = await (
        User.where(lambda user: user.active == True)
        .order_by(lambda user: user.age, "desc")
        .limit(10)
        .all()
    )
    assert [user.username for user in users] == ["grace", "ada", "alan"]
```

Each page runs from four rows (ada, grace, alan, edsger), declared once
in `docs/examples/concepts.py` with its Annotated twin. Every code block
runs under the harness: either a snippet from that script or a
`{test=run prelude=concepts}` fence. The two blocks left compile-only
say why in their fence (a pytest fixture, and a Postgres-only URL).

## Facts corrected (checked by running them)

- **Architecture.** The dated "implementation status" note, the
`docs/plans/` link and the "Phase 8.5" claim are gone.
`build_model_schema()` no longer exists: the metaclass compiles
SchemaIR, and `connect()` installs it into the engine. The
schema-changes section links to Auto-migrate rather than restating the
flags, and names no tests or crates.
- **Identity map.** The tabbed example is now runnable twins. The
`RuntimeError` text is shown. Rollback eviction and bulk-update eviction
were run and both hold.
- **Type safety.** `save()` does **not** validate. Setting `alan.age =
"old"` and then calling `save()` wrote `"old"` to SQLite with only a
serializer warning. The page now says validation runs when an instance
is built (construction and `create()`), and points to
`validate_assignment`. The field-completion-in-lambdas claim is gone.
The `ValidationError` output is the real one. The `ferro/_core.pyi` path
is fixed.
- **Backends.** Postgres JSON is `jsonb` (`db_type="json"` for `json`).
The type table now links to the generated table in Models and fields
instead of repeating it. Raw SQL links point to `raw-sql.md`. The
Supabase claim is dropped: CI runs `postgres:17`, not Supabase, and the
page links Connections › Supabase instead. The troubleshooting how-to is
gone: `Engine not initialized` already lives in the FAQ, the casts live
in Raw SQL and the SQLite drift case lives in Schema changes.
- **Performance.** The identity map is weak-valued and session-scoped.
`include()` loads forward foreign keys today, and BackRef/M2M population
links to the roadmap. The audit missed one: `exists()` is `count() > 0`
(`query/builder.py`), so the "prefer exists() over count()" tip was
false. It is replaced by an indexes tip. The `bulk_create` mechanics are
corrected too: Python dumps each instance, and Rust binds the values and
chunks them into multi-row `INSERT` statements.

## Audit rows answered rather than applied

- performance L "Slow/Fast comments without SQL": no SQL block. The docs
conventions say SQL is captured from a run, never typed by hand, and
#700 (public SQL inspection) is still open. The prose names the
statements instead (N `INSERT`s against one multi-row `INSERT` per
chunk).
- performance M "benchmark has no shown output": the debug build here
gave unrepresentative timings, so the page describes the two printed
lines rather than inventing a sample.
- backends H "generate the table from `ferro_ddl_lowering`": done by
linking to the generated `docs/includes/field-types.md` (#708) instead
of keeping a second table.
- type-safety: `assert_type` on `User.where(...).first()` fails under ty
0.0.56, which infers `Query[User | UnspecializedTypeVar]` for
`User.where(lambda ...)`. The page only asserts what ty confirms (`get`,
`get_or_none`, `all`, `count`, `exists`). This looks like a ty inference
issue with `Predicate[Self]`. It may deserve its own issue.

## Verify

Ran the ticket's Verify block on `646bc32` (round 1: `02ebf8c`, below):
passed (strict build no issues; `tests/test_docs_examples.py` 193
passed, 2 skipped; `_docs_checks docs/pages/concepts` exit 0).
TDD: RED `python -m tests._docs_checks <copies of the five original
pages>` exited 1 with 7 one-letter-lambda violations (identity-map:103,
performance:52/58/76, type-safety:51/52/53); GREEN `python -m
tests._docs_checks docs/pages/concepts` exited 0, and the 21 concepts
examples pass under `pytest -k concepts`.
Verify block corrected: no

Full suite: 3402 passed, 3 failed. All three are in
`tests/test_docs_migrations.py` and concern
`guide/row-level-security.md` step files missing from
`STEP_FILE_SOURCES` (from #715). This PR touches neither file.

## Interfaces

Interfaces changed: none

## Outside Files owned

- `docs/examples/concepts.py`, `docs/examples/concepts_annotated.py`,
`docs/examples/_preludes/concepts.py` (new): the runnable model twins
and prelude that the acceptance criteria require.
- `docs/pages/howto/multiple-databases.md`, `src/ferro/models.py` (one
link each): they linked to identity-map anchors that the sentence-case
headings renamed (`#no-session-no-identity` is now
`#sessionless-operations`, `#eviction-and-invalidation` is now
`#eviction`).

## Notes for the reviewer

- A hydrated instance's `model_dump()` lists fields in alphabetical
(column) order, not declaration order as a constructed instance does.
The type-safety example compares with a dict, so the page does not
depend on that order. It may be an I-2 observational-equivalence gap
worth an issue.
- The nav labels in `zensical.toml` stay Title Case ("Identity Map",
"Type Safety", "Database Backends") because that file is not owned here.
- No new glossary terms.

- [ ] Exit step: issue #718 status updated (progress comment at pr-open)
before merge.

## Fix report (review round 1)

Rebased onto `origin/feat/docs-rewrite`. The strict build on the rebased
head finds no dead anchors.

- **type-safety.md:3 (Important):** the opening said every write is
validated, which `save()` disproves. It now reads "building an instance
validates its fields at runtime". The Pydantic models section already
says what is validated and when: construction and `create()` validate,
an assignment is not validated unless `validate_assignment=True`, and
`save()` writes the values the instance holds.
- **performance.md Benchmarking (Important):** the lead-in now says the
block runs inside a [session](../guide/connections.md#sessions).
- **backends.md unsupported URL (Minor):** I re-ran it and changed the
block to the real exception, `InterfaceError: DB Connection failed for
mysql://***: Unsupported database URL scheme 'mysql'. ...`. A result
sentence now follows the block: the failed call registers no connection,
so you can fix the URL and call `connect()` again (also checked by
running it).
- **performance.md "today" (Minor):** removed.

Verify re-ran on `02ebf8c`, the pushed HEAD: strict build found no
issues; `tests/test_docs_examples.py` 195 passed, 2 skipped;
`_docs_checks docs/pages/concepts` exit 0. Verify block corrected: no.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ts, squash and a migrations reference (#714) (#744)

Closes #714

## What

The 664-line migrations guide was a tutorial wrapped around five how-tos
and a stack of reference tables. It is now four pages:

```diff
 guide/schema/migrations.md               the loop: init, new, read, up, check, down, --replace, the PK recipe
+guide/schema/renames-and-retirements.md  rename hints, a rename under a rolling deploy, retiring a column or a label
+guide/schema/squash.md                   squashing a feature branch's migrations (Range, Inheritance)
+reference/migrations.md                  step file headers, step classes, door coverage, auto-migrate per backend, PassReport
```

Each reference table now has one home. The guides that used to carry a
copy (overview's door table, auto-migrate's per-backend matrix and its
short summary, deploying's expand/contract table, migrations' header
table and its `check`/`status`/`drift` exit-code table) keep a summary
in prose and link to it. The exit codes stay in the CLI reference, which
already held them.

Each new page has one running example. The renames page declares one
`Author` at the top (from a new `base` region in
`migrations_rename.py`). Its three examples each change that model once,
and a test pins that each example's "before" state is that model. The
squash page follows one teams branch on top of the loop's
`0001_create_author`. Its rename-inside-the-range example squashes to
the same `0002_teams`.

## Verify

Ran the ticket's Verify block on `cf83b7a` (rebased on
`feat/docs-rewrite`): passed (strict build, `test_docs_examples.py`,
`test_docs_migrations.py`, `tests._docs_checks` over the owned pages).
TDD: RED `uv run --no-sync pytest tests/test_docs_migrations.py -k
"registry_entry or step_file_has_a_source or renames_page or squash or
rename"` failed against the old pages and examples (12 failed: the six
`starts_from_the_page_s_running_example` cases, the output and step-file
registries, the `squash` output, three quoted step files). GREEN: the
same file passes in full (189 passed, 1 skipped).
Edge cases run by hand: renaming the PK recipe's `lambda book:` to
`lambda t:` makes `tests._docs_checks` exit 1 naming
`migrations.md:399`. The retire-label example's error check `raise`s in
its `else:` when the label is accepted.
Full suite: `uv run --no-sync pytest -n 8 --no-cov` passed (3422 passed,
341 skipped, 2 xfailed).
Verify block corrected: no

## Interfaces

Interfaces changed: none. Produces `guide/schema/squash.md`,
`guide/schema/renames-and-retirements.md` and `reference/migrations.md`.
They are not in the nav, and `zensical.toml` is untouched; the section
globs already pick the pages up.

## Moved anchors, for #723's redirects

| Old | New |
| :--- | :--- |
| `guide/schema/migrations.md#renames` |
`guide/schema/renames-and-retirements.md#rename-hints` |
| `guide/schema/migrations.md#retiring-a-column` |
`guide/schema/renames-and-retirements.md#retiring-a-column` |
| `guide/schema/migrations.md#retiring-an-enum-label` |
`guide/schema/renames-and-retirements.md#retiring-an-enum-label` |
| `guide/schema/migrations.md#headers` |
`reference/migrations.md#step-file-headers` |
| `guide/schema/migrations.md#changes-existing-rows-need-a-value-for` |
`guide/schema/migrations.md#backfills` |
| `guide/schema/migrations.md#checking` |
`guide/schema/migrations.md#check-status-and-drift` |
| `guide/schema/migrations.md#going-back` |
`guide/schema/migrations.md#reverting-with-down` |
| `guide/schema/migrations.md#editing-a-migration` |
`guide/schema/migrations.md#edited-migrations` |
| `guide/schema/migrations.md#two-branches-one-number` |
`guide/schema/migrations.md#duplicate-migration-numbers-and-replace` |
| `guide/schema/migrations.md#squashing-an-epics-migrations` |
`guide/schema/squash.md` |
| `guide/schema/migrations.md#a-rename-inside-the-range` |
`guide/schema/squash.md#renames-inside-the-range` |
|
`guide/schema/migrations.md#every-database-that-applied-the-originals-inherits-the-squash`
|
`guide/schema/squash.md#inheritance-on-databases-that-ran-the-originals`
|
| `guide/schema/migrations.md#a-squash-is-pre-release` |
`guide/schema/squash.md#squash-timing` |
| `guide/schema/migrations.md#squash-or---replace` |
`guide/schema/squash.md#squash-vs-replace` |

`#install-and-set-up` keeps its id under the new heading "Installation
and setup". `overview.md#what-each-door-covers` stays, now as a short
summary. Every in-repo link to a moved anchor is updated, and the strict
build checks them.

## Audit findings

- **H, line 214 (table hint):** fixed already by #713. The text now
lives on the renames page.
- **H, lines 112–121 (`0001` SQL):** fixed and pinned by #713.
Unchanged.
- **H, lines 327–339 (SQLite rebuild):** the rebuild is quoted once, on
data-steps (pinned). The migrations guide lists the four rebuild steps
and links there. The rebuild refusal there now uses the running example
(`author.nickname` made required on the loop's database).
- **M, line 598 (pins paragraph):** deleted. The squash page states the
behaviour instead: "A squash writes exactly the files `new` would write
across the range."
- **M, lines 458–598 (squash section):** moved to its own page. The page
says "feature branch", not "epic". Headings: "Squash timing",
"Inheritance on databases that ran the originals", "Renames inside the
range", "`squash` vs `--replace`".
- **M, page kind:** split as above, with two departures from the audit's
suggestion:
- "Changing a primary key" stays on the migrations guide, because `ferro
migrate new`'s refusal prints "see the Migrations docs § Changing a
primary key".
- `--replace` stays in the loop as "Duplicate migration numbers and
`--replace`", because the ticket gives it no page.
- **M, inline-code commands:** `init`, `--sql-step`, `--data-only
--data-step`, `--replace`, `down --yes` and `rekey` are now fenced
`bash` blocks, each with a lead-in and a result.
- **M, lines 7 and 92 (lead-ins):** added.
- **M, `add_x`/`add_y` placeholders:** the two branches are now your
`0002_author_bio` and the teammate's `0002_author_nickname` (the loop's
migration). The regenerated migration reads `0003_author_bio`.
- **M, I-7 in the retirement table:** the cells are prose ("required,
indexed" → "nullable, indexed, `retired=True`").
- **L, header table version history:** moved into a self-contained note
under the table.
- **L, "a one-way step for older ferro releases":** replaced by a note
at the end of "Squash timing". It links the 0.24→0.25 note on the
deploying page through a new `squashes-and-older-releases` anchor, so
the link lands on the note, not the section top (carried from #713's
review).
- **L, headings:** renamed as the audit suggests.
- **L, GitHub `main` links to examples:** the pages name the example's
repository path in code, with no link. A link to a tag would be
release-bound, and embedding a whole `main()` would bury the page.
- **L, `migrations_retire.py` default:** now `Field(default=None,
index=True, retired=True)`.
- **L, empty `FerroField()` in the retire-label twin:** removed. Every
migrations twin, `migrations_quickstart_annotated.py` included, now uses
`Annotated[T, Field(...)] = default`, with no `FerroField`.
- **PassReport kinds:** no page held this table any more (only
`Report`'s docstring listed some kinds). `reference/migrations.md` now
lists every `role` and every `ReportKind`. A test pins both against
`ferro.pass_report.Role` and the Rust `ReportKind` enum, in order.

## Notes for the reviewer

- **Outside Files owned:**
- `docs/pages/howto/upgrade-guide.md` (merged #707): two link lines that
pointed into the moved squash and retirement sections now point at
`squash.md` and `renames-and-retirements.md#retiring-a-column`.
- `docs/pages/guide/models-and-fields.md`, `src/ferro/base.py` and
`src/ferro/metaclass.py`: their links into moved anchors now point at
the renames page. The strict build fails on the page link; the docstring
links rendered dead.
- `tests/test_docs_migrations.py` (owned) gains a scenario for
`guide/row-level-security.md`'s quoted `0002_invoice_rls` step files.
#715 merged that page with no `STEP_FILE_SOURCES` entry, so
`test_docs_migrations.py` was red on `feat/docs-rewrite`. The quoted
files match what `new` writes.
- **Session wrapper criterion:** the new pages and the reworked guide
make no ORM call in a block. Their blocks are models, CLI commands and
output, and generated SQL. The one data step (the PK recipe) is marked
`{test=compile reason=...}` and says it runs inside the step's own
transaction.
- **Running-example limits:** the migrations guide's "Hand-written
steps" outputs come from a fresh project (`0002_seed`), and the PK
recipe uses its own `Writer`/`Book`, as before.
- **New terms:** none for `docs/includes/abbreviations.md`.

## Fix report (review round 1)

- **Spec, Critical (dead anchors on the trial merge):** rebased on
`feat/docs-rewrite` (`b0e5348`). `howto/upgrade-guide.md:74` now links
[Squashing a feature branch's migrations](../guide/schema/squash.md),
with "epic" gone from the link text, and `:100` links
`renames-and-retirements.md#retiring-a-column`. I grepped `docs/pages`
and `src/ferro` for links into the split pages: none points at a moved
anchor, and the strict build on `cf83b7a` reports no issues.
- **Quality, Important (`migrations.md:55`):** removed the repeated ",
offline, and says what to run:".
- Verify re-run on the rebased head `cf83b7a`: strict build clean,
`test_docs_examples.py` 202 passed, `test_docs_migrations.py` 189
passed, `tests._docs_checks` exit 0.

## Exit steps

- [ ] Issue status updated: #714 closes via this PR's merge into
`feat/docs-rewrite` (the orchestrator closes it), and its progress
comment shows `pr-open`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ings docstrings as reference entries (#720) (#747)

Closes #720

## Summary

The docstrings for connections, sessions, fields, raw SQL, row security,
settings and exceptions are now short Google-style reference entries.
Each public object has a one-line summary, every parameter in `Args:`,
Markdown autorefs and an example that runs. The long explanations were
already on the guide pages. The docstrings now link to them, and the few
facts the guides were missing were added.

`ferro.migrate`'s `Returns:` used to render as five table rows of
sentence fragments. It is now one row:

```diff
 Returns:
-    The ``PassReport``: every statement the pass sent to the database, in
-    order (``ExecutedStatement(subject, sql, role)``), and every warning
-    it raised (also raised as a ``UserWarning``). A pass that fails
-    partway raises its usual error with ``.report`` set to what committed
-    before the failure.
+    Every statement the pass sent, in order, and every warning it raised,
+        as a [`PassReport`][ferro.PassReport]. Each warning is also raised
+        as a `UserWarning`.
```

`version`, `reset_engine` and `set_default_connection` used to render
the Rust doc comment ("Selects the default connection used by legacy
unqualified operations", plus a rustdoc `# Errors` heading). They are
now thin Python functions in `ferro/__init__.py` with their own
docstrings:

```text
ferro.set_default_connection(name)   "Set the connection `engines.session()` opens when called without a name."
  -> _core.set_default_connection(name)
```

Where the essays went:

```text
connect (95 lines)       -> guide/schema/auto-migrate.md (already there), linked; one line added to guide/connections.md
PoolConfig (~90 lines)   -> concepts/row-level-security.md#connection-delivery (+ "A late first setting" bullet)
Session.set_config (68)  -> concepts/row-level-security.md#visibility-of-a-new-value (+ the failure case)
```

## Evidence

- **Before:** `ruff check --select D417` on the owned files found 3
errors: `Field` (`db_check`, `db_type`, `reverse_index`), `ManyToMany`
(`**kwargs`) and `execute` (`*args`, `session`, `sql`, `using`). A
griffe sweep of the owned modules found about 150 public objects or
fields with no docstring, 51 Sphinx roles and 6 RST `::` blocks.
**After:** D417 is clean. The sweep finds nothing missing except
Pydantic's `model_config`. No roles or `::` are left in the owned files,
and the API pages render none.
- Error path: when the `autocommit` line is removed from `execute`'s
`Args:` (in a scratch copy), `ruff check --select D417` fails with
`Missing argument description ... execute: autocommit`.
- `tests/test_docstring_examples.py`: 141 passed. There are 26 new
runnable examples in the owned modules, all marked `{test=run ...}`.

## Verify

Ran the ticket's Verify block on `ea7c2a9`: passed. The strict build
found no issues; 141 docstring examples passed; `_docs_checks` was
clean; `ruff --select D417` was clean; `test_docs_site.py` gave 13
passed. The full suite (`pytest -n 8 --no-cov`) gave 3498 passed, 342
skipped and 2 xfailed. The RLS step-file failures the brief mentioned
were already fixed on the base by #744.
TDD: RED `ruff check --select D417 <owned files>` failed with 3 errors
as expected (above); GREEN, the same command printed `All checks
passed!`.
Verify block corrected: no

## Interfaces

Interfaces changed: none. `engines.session()` is documented in full,
with Args, Returns, Raises and two examples. `ModelConnection` already
had full docstrings from #719, and #722 renders it.

## Notes for the reviewer

- **Engine helpers as Python wrappers.** The site builds API pages by
inspecting the compiled `_core` module, not by reading `_core.pyi`. A
docstring in the stub would never render, and the docstring harness
would not run its examples either, because tests import `ferro` first.
Each of `version`, `reset_engine` and `set_default_connection` is now a
one-line Python wrapper in `__init__.py` with the docstring, so the
site, `help()` and the harness all read one text. `_core.pyi` is
unchanged. The Rust doc comments in `src/connection.rs` (outside Files
owned) still use the old wording and only appear in
`help(ferro._core.reset_engine)`.
- **`PoolConfig.validate` is now `_check_bounds`** (audit row). The
public name shadowed Pydantic's deprecated `BaseModel.validate`
classmethod and rendered as an undocumented member. No caller used it.
- **Preludes.** The `connect`, `reset_engine`, `set_default_connection`
and `clear_registry` examples use `prelude=connections`, not
`reference`. The `reference` prelude holds a session open, and
`reset_engine()` inside it breaks the prelude's exit.
- **Scope of "public".** Every non-underscore object in the owned
modules now meets I-14. That includes internal modules no page renders
(`columns`, `registry`, `state`, `composite_*`). `_core` is a private
module, so its other stubs are untouched. Docstrings of
underscore-prefixed helpers keep their maintainer notes, ADR numbers
included. User-facing error messages are unchanged (no behaviour
change), including `retired_clause`'s "(ADR-0053)".
- **Audit rows that turned out wrong or moot.** The `exceptions.py` row
about `ck_pair_at_most_one_side`: that name is in
`docs/pages/api/exceptions.md` (not owned), and it already has the
`ck_<table>_<suffix>` form (table `pair`). The audit's `fetch_one` claim
"the rest of the result is fetched and discarded" is not what I wrote.
The docstring says "Ferro does not add `LIMIT 1`; write it when more
than one row can match", which matches the Raw SQL guide. The `connect`
row said renames go to `ferro migrate new`. Auto-migrate now applies
`renamed_from` itself (`RENAME COLUMN`), so the docstring sends only
primary-key changes to `#changing-a-primary-key`.
- **Dead anchors outside my files**, found by checking every link on the
built API pages: `base.py` links
`models-and-fields.md#overriding-the-column-type-with-db_type`,
`models.py` links `#composite-constraints`, and `query/builder.py` links
`queries.md#every-traversed-hop-is-an-inner-join`. None of the three
headings exists. The strict build does not check anchors on API pages.
- **Outside Files owned:** none.
- [x] Exit step: issue status is current (progress comment on #720
updated; `Closes #720` above; the orchestrator closes the ticket after
the merge into `feat/docs-rewrite`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#748)

Closes #722

## What

The API reference had no entry for `QueryNode` or `FieldProxy`, the two
types every predicate signature names. `Query` was documented as
`ferro.query.builder.Query`. Now every exported name has exactly one
entry under the path a reader imports it from:

```
::: ferro.query.builder.Query      ->  ::: ferro.query.Query
::: ferro.query.Row                ->  ::: ferro.Row
::: ferro.migrations.steps.chunked ->  ::: ferro.migrations.chunked
::: ferro.migrations.alembic.get_metadata -> ::: ferro.migrations.get_metadata
```

`FieldProxy`, `RelationProxy`, `QueryNode`, `ValueExpr`,
`ModelConnection`, `EngineManager.session`, `RunReport`, `StatusReport`
and `CheckReport` get entries. `show_if_no_docstring` is off and
`show_category_heading` is off. Each API page is titled after its
objects (`Query`, `Model`, `ferro.migrations`, `connect, Session and the
registry`, ...).

## Verify

Ran the ticket's Verify block on `bdff07d`: passed (strict build: no
issues; `tests/test_docs_site.py`: 19 passed). Full suite: 3514 passed,
342 skipped, 2 xfailed.
TDD: RED `uv run --no-sync pytest tests/test_docs_site.py -q --no-cov -p
no:cacheprovider` on `fa03a58` failed as expected: `exported names with
no API entry: ['ferro.Row', 'ferro.Rows', 'ferro.migrations.chunked',
..., 'ferro.query.QueryNode', ...]`, plus the non-public-path, title,
empty-entry and dead-anchor tests. GREEN: the same command passed
(19/19).
Error path: deleting `::: ferro.query.QueryNode` fails with `exported
names with no API entry: ['ferro.query.QueryNode']`.
Verify block corrected: no

## Interfaces

Interfaces changed: none

`test_every_export_has_an_api_entry` is in `tests/test_docs_site.py`, as
the ticket names it. Beside it: `test_api_entries_use_public_paths`,
`test_api_pages_are_titled_after_their_objects`,
`test_no_api_entry_renders_empty`,
`test_every_public_object_and_member_has_a_docstring` (griffe; with
`show_if_no_docstring` off, an undocumented member would vanish from the
page without a warning) and
`test_links_from_api_pages_land_on_existing_anchors`.

## Notes for the reviewer

- **Not exported, documented under their module.** `RunReport`,
`StatusReport`, `CheckReport`, `ModelConnection`, `EngineManager` and
`Subject` are in no `__all__`, so they render as
`ferro.migrations.runner.RunReport` and so on. They are listed in
`RETURNED_TYPES` in the test. Exporting them would be an API change,
outside this ticket.
- **`Relation`** now shows `add`, `remove` and `clear`
(`inherited_members`), and `Query` filters them out, since they work
only on a many-to-many relation (audit row). `where()` and the other
query methods stay on `Query`.
- **`model_config`** is filtered globally: on `PoolConfig`,
`FerroSettings` and `DatabaseSettings` it was the only member with no
docstring, and on `Model` and `Row` it rendered Pydantic's own
docstring.
- **Audit rows applied:** the queries intro is cut to a list of links
(the `select()` table and the aggregates section stay, as a result-type
table; ADR and issue numbers are gone); the connection page is grouped
into Connecting, Sessions, Registry, Auto-migrate reports and Project
settings; the exceptions page's 150-word refusal sentence now points at
the migrations reference's Errors section; the migrations "Running
migrations" paragraph is a parameter table. The
`#aggregates-and-grouped-queries`, `#running-migrations`,
`#data-step-declarations` and `#the-test-harness` anchors that guides
link to are kept.
- **Audit rows already resolved upstream:** Sphinx roles, Rust `#
Errors` headings, source blocks and dunder members no longer render
(#701, #719 to #721). The `Field` docstring rows (Pydantic parameters,
broken Pydantic links) are docstring rewrites and stay out of scope.
- No audit row turned out wrong.

### Outside Files owned

- `src/ferro/base.py`, `src/ferro/models.py`,
`src/ferro/query/builder.py`: link-only fixes to three renamed guide
headings, as the brief allows.
- `docs/pages/guide/queries.md`: one link pointed at
`api/queries.md#include-and-populated-relations`, a section this PR
removes; it now points at `#ferro.query.Query.include`.
- `tests/test_docs_migrations.py`: its page check counted every `:::
ferro.migrations.*` target; it now counts only
`ferro.migrations.<name>`, so the report entries under their modules do
not break it.
- `docs/solutions/patterns/docstring-conventions.md` (I-5): the strict
build does not check anchors in docstring links, and the tests that now
do.

## Exit steps

- [ ] Issue status updated: #722 is closed by the orchestrator after
this PR merges into `feat/docs-rewrite`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
) (#749)

Closes #723

## What

The site had nine tabs, two of them a single page, and two pages both
called "Migrations". It now has six:

| Tab | Holds |
| :--- | :--- |
| Get started | Ferro ORM (home), Why Ferro, Installation, Quickstart |
| Guide | the product areas; Schema management now includes Renaming and
retiring, Squashing, and Adopting migrations |
| How-to | the five "How to ..." recipes and Migrate from SQLAlchemy |
| Concepts | adds Row-level security and session settings |
| Reference | Python API (the eight API pages), CLI, Configuration,
FerroError and its subclasses, Migrations reference, Row-level security
|
| Project | FAQ, Roadmap, Upgrading between versions, Changelog,
Contributing |

Every nav label is now its page's H1, and every page has a one-sentence
`description` in front matter, which reaches the page's `<meta
name="description">`. The five pages new in this epic carry `status:
new`.

Old URLs keep working. A reader with a bookmark to
`/howto/upgrade-guide/#upgrading-to-014` lands on
`/upgrading/#upgrading-to-014`; one following an old link to
`/guide/row-level-security/#the-nullif-contract` lands on
`/concepts/row-level-security/#policy-expressions`.

## Changes

- Moved: `howto/adopting-migrations.md` →
`guide/schema/adopting-migrations.md`; `howto/upgrade-guide.md` →
`upgrading.md`. Deleted: `getting-started/next-steps.md` (#705 folded
its content into the quickstart's "Next steps").
- `redirect_maps`: one entry per moved or deleted page, each Next steps
section → the quickstart's Next steps, the 14 anchors #744 lists, and 19
anchors of the old row-level security guide (derived by diffing its
headings at `2a45de9` against the three current pages). An anchor on a
page that still exists redirects through the theme's `redirect.json`.
- Retitled so labels can equal H1s: "Typed query predicates" (sentence
case), "Alembic bridge", "Migrations reference" (it shared "Migrations"
with the guide), "Upgrading between versions", and `changelog.md` now
has its own "# Changelog" and embeds `CHANGELOG.md` from line 2
(CHANGELOG.md itself untouched, I-10).
- `llmstxt` sections: Next steps dropped, "Community" → "Project" with
the upgrade page added.
- Links to the moved pages updated in `docs/pages`, the
`ferro.migrations.baseline` docstring, and the example files'
docstrings.

## Verify

Ran the ticket's Verify block on `d1a7219`: passed (strict build: no
issues; `test_docs_site.py` 30 passed; `test_docs_migrations.py` 198
passed, 1 skipped; `test_docs_examples.py` 194 passed, 2 skipped). Full
suite: 3525 passed, 342 skipped, 2 xfailed.
TDD: RED `uv run --no-sync pytest tests/test_docs_site.py -q --no-cov -p
no:cacheprovider` failed as expected (11 failed: six tabs, nav coverage,
labels, descriptions, status, redirects); GREEN the same command passed
(30/30).
Verify block corrected: no

### Test scenarios

- Happy path: the strict build passes with every page in the nav
(`test_every_page_is_in_the_nav_once`, `test_nav_has_the_six_tabs`,
`test_every_nav_label_equals_its_page_h1`,
`test_nav_labels_are_unique`).
- Integration: the old URLs of the moved pages resolve to redirects in
the built site (`test_moved_page_is_gone_and_its_url_redirects`, three
cases), and every moved anchor maps to a section that exists and no
longer exists at its old address
(`test_moved_anchors_redirect_to_existing_sections`).
- Added: `test_every_page_has_a_one_sentence_description`,
`test_page_descriptions_reach_the_built_pages`,
`test_pages_new_in_the_rewrite_are_marked_new`.

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- **Outside Files owned:** the new tests live in
`tests/test_docs_site.py`, because they need its `built_site` fixture
(#722 owned that file and has merged). Link fixes touch
`src/ferro/migrations/_baseline.py` and the docstrings of
`docs/examples/upgrade_*.py` and `migrations_adopting.py`, as the
brief's scope allows.
- **Ticket vs merged titles:** the ticket names "Testing with Ferro".
The how-to tickets already retitled that page "How to test a Ferro app
with pytest", which is distinct from "Testing migrations", so I kept it,
and kept the other how-to H1s as those tickets wrote them.
- **Audit rows that turned out moot:** `guide/migrations.md` is no
longer a stub to exclude from search; #701 replaced it with a generated
redirect. The Next steps rows are resolved by the deletion.
- The "Schema management" section's first page is also titled "Schema
management" (its H1); the label rule forces this, and the site has no
section index pages.
- The anchor redirects on pages that still exist work through the
theme's client-side `redirect.json`, which Zensical builds from
`redirect_maps`; a reader with JavaScript off lands at the top of the
page.
- New glossary terms: none.

## Exit steps

- [ ] Issue status updated: #723 closes via this PR's merge into
`feat/docs-rewrite` (the orchestrator closes it), and its progress
comment shows `pr-open`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #724

## What

The docs checks that were opt-in are now gates. Say a docstring gains
this:

```python
def where(...):
    """Return a :class:`Query`.

    Returns:
        A query over the matching rows; nothing runs until you await one
        of its methods.
    """
```

The built page shows `:class:` as literal text and splits the `Returns:`
description into two table rows, the first ending at "await one". Both
now fail `tests/test_docs_site.py`. The two `Model` docstrings that
really did split this way (`where`, `using`) are fixed.

- **Rendered site** (`markup_defects`, `docstring_warnings` in
`tests/test_docs_site.py`): fails on a literal Sphinx role, a
`[text][target]` left unresolved, a list merged into its paragraph (a
`<p>` holding a `- ` or `1. ` line), a docstring section rendered as
prose, a section table row that stops mid-sentence, and any griffe
warning in the build log (griffe drops an unreadable `Args:` item with
only a warning, which `--strict` lets through). A second build, of a
page and a module seeded with each defect, shows each check catching it.
It also fails on a `# noqa` in any rendered code block, and checks that
CI's docs job runs every `tests/test_doc*.py` file and the docs lint.
- **Page fences** (`tests/_docs_harness.py`): every Python fence on a
page says how it is tested: `{test=run prelude=<name>}`, `{test=compile
reason="..."}` or `{test=pytest path="..."}`. An unmarked fence,
`{compile-only="..."}`, a bare `{test=run}`, an unknown mode or a mode
without its setting fails with the marker to write. The 17
`compile-only=` fences are converted, `upgrading.md`'s bare `{test=run}`
runs with `prelude=upgrade`, and the 7 unmarked API/reference fences
state why they cannot run.
- **Docs lint**: `tests/test_docs_lint.py` runs `python -m
tests._docs_checks docs/pages docs/examples src/ferro` (exits 0) and
fails on a seeded `lambda t: t.id` page.
- **`tests/test_documentation_features.py`**: the four many-to-many
tests skipped with a reason that pointed at a deleted page are gone.
`add`, `remove`, `clear` and the reverse side all run in
`docs/examples/relationships.py`. So does every other hand copy, except
an `|` predicate and two concurrent transactions, which are what the
file keeps.
- **`# noqa: E711/E712`**: 43 comments are removed from `docs/examples`
and the pages. The ignore is now a ruff per-file ignore for
`docs/examples/*` in `pyproject.toml`.
- **CI**: the `docs` job builds the extension, runs the docs lint and
every docs test file.
- **Review follow-ups from #723**: the schema overview's nav label and
H1 are both "Overview of schema management", and the `llmstxt` section
is "Get started".

## Verify

Ran the ticket's Verify block on `ccc1616`: passed (strict build clean;
site 43 passed; examples 202 passed, 2 skipped (Postgres); docstrings
141 passed; migrations 198 passed, 1 skipped; lint + features 4 passed;
`_docs_checks` exit 0). Full suite: 3514 passed, 338 skipped, 2 xfailed.
TDD: RED `pytest tests/test_docs_site.py -k "markup or docstring_parses
or seeded"` failed as expected (1 failed, 10 passed: the seeded checks
passed and the real tree showed the two split `Returns` rows and four
merged lists); GREEN after the fixes (41/41). RED `pytest
tests/test_docs_examples.py -k "fence or prelude or compile or refused"`
failed at import (`page_fence_mode` did not exist); GREEN 17/17, then
the page sweep listed 25 unmarked or misspelled fences, now 202 passed.
Verify block corrected: no

## Interfaces

Interfaces changed: none

## Notes for the reviewer

- **Outside Files owned**: `zensical.toml` and the H1 of
`docs/pages/guide/schema/overview.md` (#723 review follow-ups, asked for
in this ticket's brief); `src/ferro/models.py` (indent two `Returns:`
continuation lines, which the new gate caught); `CONTRIBUTING.md` and
`docs/pages/guide/schema/deploying.md` (a blank line before four lists
that rendered as paragraphs, and 4-space nesting in the release steps);
`AGENTS.md` (the two ticket-number references, rewritten to describe the
gate); `docs/solutions/patterns/docs-page-conventions.md` (the three
fence markers, `{test=pytest path=...}` included).
- `ruff format` rejoined the lines in five example files once their `#
noqa` comments were gone, so a few lambdas now sit on one line.
- Docstring fences keep their rule: an unmarked one is compiled. Only
page fences must carry a `test=` mode, because the audit row and the
acceptance criterion are about inline page blocks; 19 docstring fences
are compile-only today.
- Audit row 6 counted 184 compile-only page blocks. On this branch,
after the page tickets, 8 page fences were unmarked and 17 used
`compile-only=`. The rest already ran or carried `test=compile reason=`.
- `test_documentation_features.py` is a `backend_matrix` file, and the
docs examples run on SQLite only. CRUD and query behaviour runs on both
backends in `test_crud.py` and `test_query_builder.py`. Many-to-many
`add` and `remove` run on Postgres in `test_auto_migrate.py` and
`test_catalog_cache.py`. The four deleted many-to-many tests had been
skipped, so they never ran on either backend. I found no Postgres test
of `clear()` or of reading the reverse side, and this PR does not add
one. If you want that covered on Postgres, say so and I will put it back
here as a backend-matrix test.

## Exit steps

- [ ] #724 status confirmed (closed by this PR's merge into the
integration branch, via the orchestrator)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g and .sql())

Resolves the docstring-vs-terminal conflicts in the query builder by keeping
main's terminals and signatures with the rewrite's reference docstrings; the
Relation overrides keep their documented examples as terminals at runtime
behind main's typing descriptors. The Logging section lands before the
Connections guide's See also; RenderedSql, REDACTED and Terminal join the
Query API page under their public paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…tic docstring harness

After merging main: export Terminal from ferro.query so the API page and
every autoref use a public path; make RenderedSql.params a one-line field
docstring and indent Terminal.sql's Returns continuation so neither renders
as table fragments; mark the Logging section's fence to run under the
connections prelude; and make the docstring harness load griffe statically
and skip compiled sources, since an imported ferro let griffe hand back
_core's .so for the new RenderedParts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…own test (#725) (#775)

The Queries, Aggregations, Relationships and Mutations guides show the SQL Ferro sends beside each example: 132 sql blocks, each opening with a marker (-- <example>.py:<snippet> (<dialect>)) that tests/test_docs_sql.py regenerates from the example's own .sql() (or the statement log for an instance write) and compares byte for byte, with SQLite and PostgreSQL tabs where more than placeholders differ. A .sql() failure must match the call's failure or the capture fails naming the example line. The page conventions carry the recipe.

Interfaces changed: none

Closes #725

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
@0x054

0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Verdict: FIX FIRST

One Critical (a docs commit removed two ferro migrate status flags) and a Spec FAIL (the CLI reference omits --allow-ahead; captured SQL covers four pages where the epic says every query); the Lint job is also red on the head.

Spec: FAIL

  • Important · docs/pages/reference/cli.md:199-201 at 00dd767 · Missing · The ferro migrate up flag table lists only --lock-timeout. The CLI accepts --allow-ahead[=expand-only] (src/ferro/cli/migrate.py:396, shown in ferro migrate up --help), and nothing in cli.md mentions it. Every other verb's section covers every flag its --help prints; this is the only gap. · Story 17: "I want the CLI reference to list every flag the CLI accepts, including ferro migrate up --allow-ahead, so that the reference is complete."
  • Important · tests/test_docs_sql.py:51-56, docs/pages/howto/pagination.md:50-64 at 00dd767 · Missing · Captured SQL appears only on the four pages in PAGES (Queries, Aggregations, Relationships, Mutations). The other pages that run queries show none: the pagination how-to's after()/before() keyset paging, soft-deletes, timestamps, transactions, connections and the quickstart; the RLS guide's ENABLE/FORCE/CREATE POLICY block (row-level-security.md:39) and the upgrading page's blocks are typed by hand and trace to no gate. The plan has no **Narrowed:** line covering this. Ticket Show captured SQL beside query and mutation examples (needs #700) #725 scoped itself to four pages, but the epic was not narrowed. · Story 33: "the generated SQL shown beside each query and each schema feature". Implementation Decisions: "Generated SQL is shown beside each query and each schema feature."
  • Minor · zensical.toml:253 at 00dd767 · Misunderstood (reasoned) · filters = ["!^_", "!^model_config$"] replaces the filters = "public" the epic specified. The comment explains why: griffe treats dunders as public. The outcome of story 60 holds (test_model_api_page_shows_the_public_surface_only), but the plan has no **Deviated:** entry recording the change. · Implementation Decisions › mkdocstrings: "filters = \"public\"".
  • Minor · zensical.toml:42 at 00dd767 · Misunderstood · The RLS how-to ("Scope rows by tenant with Postgres row-level security") sits in the Guide tab, not the How-to tab, while its explanation and reference pages went to Concepts and Reference. · Information architecture: the RLS guide "splits into an explanation, a how-to and a reference", and the tabs are Get started / Guide / How-to / Concepts / Reference / Project.

Every other user story checked has its page, example or test: 1-16, 18-32, 34-81 (prose stories 28-40 sampled on six pages).
Cannot verify: the prose rules (stories 28-31, 35-39) across all 51 pages, which were only sampled; the social-card colour and the "Copy as Markdown" action beyond config presence.

Coherence

The slices agree on every shared interface except two low-severity seams:

  • Minor · Fence contract, pages versus docstrings. On pages, tests/_docs_harness.py (lines 106-118) refuses an unmarked fence, so every page block says {test=run prelude=…} or {test=compile reason="…"}. tests/test_docstring_examples.py:27-35 only compiles an unmarked docstring fence, with no reason required. 19 unmarked fences are rendered on API pages: src/ferro/settings.py:11, src/ferro/migrations/report.py:431, src/ferro/migrations/testing.py:56/63, src/ferro/rowsecurity.py:5, src/ferro/base.py:207 and others. Some are fragments that would not even run, such as report.head_applied.name # "0006_add_teams". The Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704 contract ("for Markdown fences and docstring examples") is enforced on only one of its two consumers.
  • Minor · Prelude docs/examples/_preludes/aggregations.py (created by Aggregations guide: real data without ties, results shown, error checks that can fail #710) is cited by no fence on any page or docstring, and by no test. Every other prelude matches the names the pages cite.

Checked and consistent: fence grammar (every marker is run, compile or pytest; every prelude= names an existing prelude); every --8<-- snippet range resolves to a [start:]/[end:] pair and no example script is left un-embedded; the SQL marker regex matches the conventions recipe and every block on the four pages; _public_paths() (from ferro.__all__, ferro.query.__all__, ferro.migrations.__all__, ferro.migrations.testing.__all__, RETURNED_TYPES) agrees with the API anchors, and FieldProxy, QueryNode, RelationProxy, ValueExpr, ModelConnection, RunReport, StatusReport, CheckReport and engines.session each have an entry; autorefs written against defining modules resolve to the public anchors; AGENTS.md I-8 and I-14 match the lint and the docstrings; all nine entry points of story 72 carry an Examples: fence marked {test=run prelude=reference}; the built_site/rendered_pages fixtures from #701 serve #722 and #724; redirect_maps extended by #723; the CI docs job runs every docs test plus the lint, pinned by test_ci_docs_job_runs_every_docs_test_and_the_lint.

Findings

Critical

  • F1 api-contract · src/ferro/cli/migrate.py:561-567 @ 00dd767 · conf 92 · docs(api) commit removes --no-steps and --no-json from ferro migrate status
    b0e5348 (docs(api): migrations, pass report and CLI docstrings, docs(api): migrations, pass report and CLI docstrings as Google-style reference entries (#721) #745) added negative="" to the steps and json_ parameters of status. cyclopts generated --no-steps / --no-json for these bool flags at base; with negative="" they no longer exist: at base migrate status --no-steps reaches config lookup, at head it fails with UnknownOptionError: Unknown option: --no-steps. Did you mean --steps?. · A CLI flag removal ships in a docs-only commit with no changelog, upgrade-guide or ticket mention; a script passing --no-json now exits 2. · Fix: drop negative="" on status so the flags stay as shipped, or decide the removal explicitly, record it in the upgrade guide and test it. · verified by: AST diff of src/ferro at base vs head with docstrings stripped; ran the cyclopts app _build() against both trees with migrate status --no-steps and --no-json.

Important

  • F2 adversarial · src/ferro/query/builder.py:2143 @ 00dd767 · conf 97 · ruff format fails on builder.py, so the PR's Lint job and All Checks are red
    The else: branch that defines Relation.all / .first() under if TYPE_CHECKING: has no blank line before @terminal; ruff format --check . reports Would reformat: src/ferro/query/builder.py. At base the file is formatted; the merge commit d070e31 introduced it. · CI run 39611415782 on PR docs: the docs rewrite — every page one kind, every block runs, the SQL beside every query (#698) #777 fails Lint & Format, and All Checks Passed requires that job. · Fix: ruff format src/ferro/query/builder.py. · verified by: ran ruff format --check and --diff in the checkout and on git show f3e46f8:…builder.py; read job 119260955695's log.
  • F3 api-contract · src/ferro/__init__.py:307 @ 00dd767 · conf 90 · PoolConfig.validate renamed to _check_bounds under a docs commit
    5c2bdd5 (docs(api) docs(api): connection, session, field, raw SQL, row security and settings docstrings as reference entries (#720) #747) renamed the public @model_validator(mode="after") def validate(self) to _check_bounds; PoolConfig.validate was a public attribute shadowing pydantic's deprecated BaseModel.validate classmethod and now resolves to that classmethod, so pool.validate() raises TypeError instead of returning self. · A public method on an exported class is removed and replaced with a different callable of the same name, unmentioned. · Fix: keep the rename only if recorded in the upgrade guide and the commit body, or restore validate. · verified by: signature/AST diff; ran PoolConfig.validate at head; searched src, tests and docs for callers (none).
  • F4 api-contract · src/ferro/models.py:796 @ 00dd767 · conf 90 · Model.select's Raises: names ValueError; the code raises AttributeError
    Author.select("zzz") and Author.select(lambda author: author.zzz) raise AttributeError: Author has no queryable column 'zzz'… (validate_query_column, nodes.py:1018); the Queries guide (queries.md:91) says AttributeError. · The API reference tells readers to catch an exception the call never raises. · Fix: AttributeError. · verified by: executed both calls at head; read validate_query_column.
  • F5 api-contract · src/ferro/session.py:331 @ 00dd767 · conf 85 · Session.query gains a PEP 695 generic signature and typed return under a docs commit
    def query(self, model_cls) became def query[M: Model](self, model_cls: type[M]) -> Query[M], with Model and Query imported under TYPE_CHECKING; the same commit adds **fields: Any to Model.create / upsert / get_or_create / update_or_create (harmless). · A type checker now sees a new bound and return type on a public method; user code that treated the result as Any, or passed a non-Model class, can start failing type checks. · Fix: keep it if intended, but say so in the commit and upgrade notes. · verified by: parsed every def/class at base and head and diffed parameters, defaults, returns, decorators, type params.
  • F6 api-contract · src/ferro/state.py:54-59 @ 00dd767 · conf 85 · The "no database route" RuntimeError text changed in a docs commit
    _NO_ROUTE_MESSAGE changed from "removed in v0.13 … see the sessions migration guide." to "removed in v0.14 … see the upgrade guide."; resolve_compile_connection_name matches it by the constant so in-repo code still works, but any external string match breaks, and "v0.14" conflicts with the Changed in 0.13 note at connections.md:187. · Fix: revert the string, or note the change and check the version claim against the changelog. · verified by: diffed state.py; grepped src, tests, docs for both texts.
  • F7 testing · tests/test_docs_examples.py:25 @ 00dd767 · conf 88 · Printed-output blocks on guide pages are hand-typed and no test compares them
    About 107 text blocks under "It prints:" on guide, how-to and concept pages (queries.md:17, raw-sql.md:31, queries.md:1324, …) are never compared with what the example script prints; only CLI blocks, SQL blocks and DDL are generated or compared, and the snippets end above their asserts because the output block stands in for the result. · Stories 4 and 32 promise the reader sees what each query returns and that every block was executed; if ordering, repr or rows change, the script's asserts still hold while the page keeps stale output, the class of defect the rewrite set out to remove. · Fix: mark each output block with its example and snippet (as the SQL blocks do), run the script once, record stdout per snippet region, and compare. · verified by: grepped the docs tests for stdout/text-fence comparison; ran predicates.py (matches today, so a missing gate, not a current mismatch).
  • F8 testing · tests/test_docstring_examples.py:24 @ 00dd767 · conf 82 · Nothing requires the nine named entry points to keep a running Examples: block
    The test runs only fences marked test=run; an unmarked fence passes. Story 72 names create, get, where, save, delete, after, before, position_of and migrate; all nine are marked today, but no test asserts it. · Dropping the marker or the section leaves every gate green. · Fix: a test that asserts each named entry point has at least one test=run fence. · verified by: scanned the nine docstrings; read the test and the site test names.
  • F9 maintainability+depth · tests/test_docs_sql.py:177 (and tests/_docs_harness.py:123, tests/test_docs_migrations.py:1241) @ 00dd767 · conf 88 · Five hand-rolled Markdown-fence parsers beside the harness one
    _docs_harness.py owns _FENCE/fenced_examples() for Python fences; test_docs_sql.py (FENCE, _sql_blocks) and test_docs_migrations.py (_fenced_blocks) repeat the identical regex and dedent loop; test_docs_examples.py (_README_FENCE) and test_docs_testing_fixtures.py (_TOML_FENCE) add two more. · One fence grammar maintained in five places; a change (tilde fences, nested fences) must land five times and the page lines they report can diverge; Docs: the Inspecting SQL guide page #767's page would add a sixth reader. · Fix: one fence reader behind the harness (fenced_blocks(path, text, language=None) returning language, info string, dedented body, opening line) that every test filters. · verified by: read all five readers; the SQL and migrations code is line-for-line the same.
  • F10 maintainability · tests/test_docs_examples.py:75 @ 00dd767 · conf 85 · Five copies of the run-in-subprocess-and-assert runner
    subprocess.run([sys.executable, …], capture_output, text, timeout, cwd=REPO_ROOT) plus the stdout/stderr assert is written in _docs_harness.run_fence, test_docs_examples.py (twice), test_docs_migrations._run_example and test_docs_sql.py:428, with different timeouts (120/180) and env handling. · Timeouts, env scrubbing and failure messages drift per module; a fix (hash seed, timeout) lands in only some. · Fix: one run_python(args, timeout, env, cwd) in the harness. · verified by: read all five call sites.
  • F11 maintainability · tests/test_docs_examples.py:60 @ 00dd767 · conf 85 · Nothing checks that an example and its _annotated twin stay equivalent
    About 23 docs/examples/*_annotated.py are hand-written copies (mutations.py vs mutations_annotated.py already differ in imports and symbols beyond field syntax); each twin only has to run, and the sole equivalence check is the migrations' byte-equal output for migrations_*. · I-7 promises the two tabs are equivalent; twins can drift in fields, data or asserted behaviour with both passing, so one tab documents a different model. · Fix: a test that imports each pair and compares the registered model schemas and printed output, or generate the twin. · verified by: grep for annotated in the docs tests; diffed the mutations pair.
  • F12 adversarial · tests/test_docs_sql.py:434 @ 00dd767 · conf 90 · The SQL gate pins PYTHONHASHSEED=0, hiding save(exclude=) SET order that varies per process
    _capture forces PYTHONHASHSEED=0 because apply_save_exclude builds the write-set as a set; mutations.md:345 and :359 show the seed-0 order (SET "updated_at" = ?, "name" = ?, "views" = ?, "email" = ?); captured under seeds 0–3 the save-exclude region gave four different orders. · The epic promises the SQL beside a query is the SQL Ferro sends; a reader's process uses a random seed, so on most runs their log does not match the page, and the gate turns real nondeterminism into a stable-looking fact. · Fix: make save(only=) / save(exclude=) write columns in model field order (save(only=) / save(exclude=) emit columns in set order, so the UPDATE text changes between runs #776), then remove the override so the gate catches future hash-order output. · verified by: ran PYTHONHASHSEED=$s python -m tests.test_docs_sql mutations.py sqlite for s=0..3; read _bind_payload.py:300.
  • F13 review-pr · docs/pages/guide/aggregations.md:9-15 @ 00dd767 · conf 85 · The page's first block is called "a whole program" but embeds a region with no imports or connect(), and says four numbers for five aggregates
    The aggregations.py:global region starts at async with engines.session(): and calls seed() and Transaction defined elsewhere, so a pasted copy raises NameError; the query asks count, sum, avg, min, max and the result sentence says "The single record holds four numbers". · This is the copy-and-run failure the epic opens with (stories 2, 4) and a wrong result sentence (story 32); Queries and Relationships get it right. · Fix: widen the region to the imports, connect and seed as predicates.py:first-query does, or drop the claim; correct the lead-in and the result sentence.

Minor

  • F14 history · src/ferro/__init__.py:313-375 / docs/pages/guide/schema/auto-migrate.md @ 00dd767 · conf 82 · The connect() rewrite dropped the SQLite non-atomic migrate caveat
    The old docstring said that on SQLite the migrate passes apply statements one at a time, a mid-run failure can leave earlier statements of a table applied, and the Alembic bridge is the route for an atomic multi-step change (added by 22617e1 on purpose); the rewrite keeps only the Postgres per-table rollback via the linked page, which says nothing about SQLite. · Fix: one sentence in auto-migrate.md's "A pass that fails partway". · verified by: git log -S'single-statement operations': added 22617e1, removed 5c2bdd5; grep of docs/pages finds nothing.
  • F15 standards-and-invariants · docs/examples/models_and_fields_annotated.py:48 (also :96, :105; mutations_annotated.py:34; upgrade_account_annotated.py:32; quickstart_annotated.py:32; timestamps_annotated.py:31) @ 00dd767 · conf 85 · Annotated twins put default_factory inside Annotated, so type checkers see a required field
    created_at: Annotated[datetime, Field(default_factory=…)] with no assigned value; ty reports missing-argument … created_at for the Annotated form and nothing for the Assignment form. · docs-page-conventions.md's "One Annotated idiom for I-7 twins" says the default stays the assigned value because type checkers do not see one inside Annotated, but never covers default_factory. · Fix: decide the factory case in the conventions and apply it to every twin. · verified by: grep across the twins; ran ty check on a scratch pair. [docs-page-conventions.md#one-annotated-idiom] [I-7]
  • F16 api-contract · src/ferro/migrations/_chunk_loop.py:1 @ 00dd767 · conf 85 · Importable module ferro.migrations.chunked renamed to _chunk_loop under a docs commit
    Done so the module stops shadowing the chunked decorator; undocumented and never in __all__, only runner.py and tests imported it. · A previously importable path disappears unmentioned. · verified by: git stat -M and grep of importers at both SHAs.
  • F17 adversarial · CONTRIBUTING.md:317-334 @ 00dd767 · conf 85 · A Python fence on the Contributing page bypasses every docs fence gate
    docs/pages/contributing.md embeds CONTRIBUTING.md:contributing, whose python fence (test_create_author(db_url)) this PR rewrote; test_docs_examples collects only fences written in docs/pages and skips fences containing --8<--, so nothing compiles or runs it and page_fence_mode never asks it for a marker. · Fix: collect Python fences from markdown pulled in by --8<-- (CONTRIBUTING.md, README.md, docs/includes), or move the example into docs/examples. · verified by: grepped every --8<-- target; ran the fence under the repo conftest (passes today).
  • F18 adversarial · tests/test_docs_site.py:554 @ 00dd767 · conf 85 · MOVED_ANCHORS claims every renamed anchor redirects; most do not
    Its docstring (and the module docstring at :26) says it lists every anchor that moved or was renamed; it covers only the migrations and RLS splits and the Next steps page. Renamed elsewhere with no redirect and no surviving id: guide/mutations#get-or-create, guide/transactions#nesting-behavior, guide/connections#sessions-recommended, faq#what-is-ferro, howto/testing#writing-your-first-test, howto/upgrade-guide#save-no-longer-upserts. · Fix: derive the old anchor set from the base pages' headings and redirect or list each, or narrow the docstring. · verified by: slugged every h2–h4 of the base pages and checked id= values in site/ and site/redirect.json.
  • F19 maintainability · .github/workflows/ci.yml:106 @ 00dd767 · conf 82 · The docs test list is hand-enumerated in the CI job
    Ten test files named one by one; a new tests/test_docs_*.py is not run by the docs job until added, and the list exists only here (the site test pins today's list). · Fix: a pytest marker (-m docs) or a glob. · verified by: compared ci.yml 106-115 with ls tests/test_doc*.
  • F20 maintainability · docs/pages/guide/queries.md:1 @ 00dd767 · conf 80 · Pages and test modules grown past reasonable size
    queries.md 1857 lines (+1355), mutations.md 766; test_docs_migrations.py 1467, test_docs_site.py 911, test_docs_sql.py 741, each mixing several concerns. · Fix: split queries.md by topic; split test_docs_migrations.py by concern around a shared harness.
  • F21 depth · tests/test_docstring_examples.py:26 @ 00dd767 · conf 80 · The fence contract is composed by each caller (compile, mode, lint, run)
    test_docs_examples and test_docstring_example each re-compose compile(…, PyCF_ALLOW_TOP_LEVEL_AWAIT), read the mode, dispatch on run, call run_fence (which parses the settings again). · Fix (follow-up): one check_fence(example, *, page) in the harness.
  • F22 depth · tests/test_docs_sql.py:516 @ 00dd767 · conf 80 · The SQL capture machinery lives inside a one-page test module and is its own subprocess entry point
    _Capture, _run_capture (patching Terminal.__call__, ferro.connect, sys.stdout, sys.monitoring), the region parser and the layout sit in the test module that Docs: the Inspecting SQL guide page #767's page is meant to reuse; the only reusable surface is importing a test module or appending to PAGES. · Fix (follow-up): a helper module (tests/_docs_sql.py) with captured(example, dialect, url) and check_block.
  • F23 depth · tests/test_docs_sql.py:662 @ 00dd767 · conf 80 · Capture unit tests assert the recorder's internal state
    The _Capture tests set capture.where by hand and assert capture.sent and capture.depth, bookkeeping rather than the interface (by_region()). · Fix (follow-up): assert through by_region() on a small example.
  • F24 review-pr · docs/pages/reference/cli.md (no test) @ 00dd767 · conf 80 · No test compares each verb's --help flags with its CLI reference section
    That gap is why --allow-ahead went missing although tests/test_cli_help.py pins placeholders. · Fix: a parametrized test over the migrate verbs asserting every --flag in --help appears in its section.
  • F25 review-pr · docs/pages/concepts/backends.md, guide/connections.md, guide/raw-sql.md, guide/row-level-security.md, guide/schema/auto-migrate.md, src/ferro/session.py @ 00dd767 · conf 80 · About 12 blocks are compile-only with reasons like "needs a PostgreSQL server" while the suite has a live Postgres under postgres_only
    Story 4 asks that every copyable block be executed; these could run under a postgres-only fence setting.

Invariants and ADRs

Checked: AGENTS.md in full (I-7, I-8, I-10, I-11, I-13, I-14), GLOSSARY.md (Include, Populated relation, Session settings, Statement log, Rendered statement, Run, Step, Squash, Inheritance and their Avoid lists), CONTRIBUTING.md, .cursorrules, docs-page-conventions.md, docstring-conventions.md, the writing-for-humans rules 1–24, ADR-0038 (tracked-database refusal on overview.md and auto-migrate.md), ADR-0049 (auto-migrate.md report text), ADR-0053 (renames-and-retirements.md), ADR-0056 (squash.md), ADR-0057 (connections.md Logging: attributes, operations, pool hooks, NullHandler); I-14 scan of every public docstring outside ferro/ir; I-8 scan of every query-method lambda in pages, examples, src and README; every page scanned for minimising words, ADR/issue numbers, dated notes, non-sentence-case headings and lead-ins (6 blocks without a colon, prose-introduced, acceptable); CHANGELOG.md untouched; git log/blame on AGENTS.md, the CLI, zensical.toml redirects, deleted examples, CI steps and both merge resolutions.
Violations: F1, F3, F5, F6, F16 [a docs epic changed public behaviour or surface unmentioned: epic Out of Scope / Implementation Decisions]; F13 [stories 2, 4, 32]; F15 [docs-page-conventions.md#one-annotated-idiom, I-7]; Spec findings above [stories 17, 33; Implementation Decisions mkdocstrings and IA].
Candidate ADRs: none new; the pool-hook and filters deviations belong on the plan's Deviated line, and the four-page scope of captured SQL on its Narrowed line if approved.
Terminology drift: "eager loading" (faq.md:98 heading; the SQLAlchemy page bullet) is a term GLOSSARY.md lists under Avoid for Include/Populated relation; both land on include() in their first sentence (advisory).

Coverage

Personas: correctness, testing, maintainability, standards-and-invariants, history, depth, adversarial (25k executable lines: the harness runs scripts in subprocesses), api-contract (rendered API surface; ferro.query.Terminal; the --help fix). security and data-migration not applicable.
Dropped below confidence 80: 0. Cannot verify from diff: the prose rules across all 51 pages (sampled); the upgrading page's 0.24-runner error text (needs a 0.24 install); whether "removed in v0.14" matches the release history; the backend-matrix and Python jobs of CI run 39611415782 (still running at review time); line attribution under sys.monitoring for a helper defined outside a region (fails loudly, never silently).
Verify: passed on 00dd767 (zensical build --clean --strict clean; docs tests 506 passed; tests/test_docs_sql.py --db-backends=sqlite,postgres 223 passed; python -m tests._docs_checks exit 0; full suite pytest tests --db-backends=sqlite,postgres -n 8 5298 passed, 69 skipped, 2 xfailed). CI on the PR: the Lint job fails (F2). Trial merge: not run (base unchanged).

Residual risk

  • The docs CI job has no Postgres service: the Postgres halves of the SQL regeneration, the migration twins and the RLS example run only in the backend-matrix job under -m postgres_only.
  • test_docs_sql.check_block accepts a marker naming any region of any example, not only the snippet embedded beside the block; every current block names its own region.
  • _Capture.by_region falls back to printed stdout when a region sends no statement, so a block for such a region compares against the region's output laid out as SQL.
  • _FENCE closes on the literal opening text without a line-start anchor; a Python fence holding a "```" string would end early (none today).
  • The Connections guide's params row says "as a tuple" without saying secret positions show ***.
  • "Eager loading" survives as a reader-vocabulary bridge on two pages.

…panel F2 on #777)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…g.validate, save() columns in field order, docstring fences marked (#779)

Panel findings on PR #777 that a docs commit must not have made: ferro migrate status --no-steps/--no-json restored (F1), PoolConfig.validate restored (F3), Model.select/Query.select Raises name AttributeError (F4); the typed Session.query (F5) and the ferro.migrations._chunk_loop rename (F16) are kept and noted in the upgrade guide. Every write (save new and fetched, save(only=), save(exclude=), bulk_create, update) now lists columns in model field order (#776), so the Mutations guide's regenerated SQL no longer depends on a hash-seed pin. The 19 unmarked docstring fences are marked.

Interfaces changed: the routeless-operation RuntimeError text reads "… removed in v0.14 (deprecated since v0.12); see the upgrade guide." (the pre-rewrite text named v0.13 and a removed page; routing was removed in v0.14.0), pinned exactly and quoted on the Connections guide.

Closes #776
Part of #698 (panel round 1 on PR #777)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
…erated, one fence reader and runner, twin equivalence, CLI reference complete (#780)

Panel findings on PR #777 in the harness and pages: every printed-output block is marked and regenerated from its example (F7, including the twelve Ferro error/warning texts, three of which had gone stale); the nine entry points must keep a running Examples block (F8); one fence reader, one subprocess runner and one check_fence in the harness (F9, F10, F21); _annotated twins generated from their Assignment scripts and pinned equal in source, SchemaIR and stdout (F11, F15); the Aggregations opening is a whole program (F13); the SQLite caveat is back (F14); the CONTRIBUTING fence is collected (F17); six renamed anchors redirect and MOVED_ANCHORS says what it covers (F18); the CI docs job runs by glob (F19); the SQL capture is a helper module (F22, F23); every migrate verb's flags appear in the CLI reference, pinned by a --help comparison (Spec, F24); the RLS how-to sits in the How-to tab (Spec); eight plain-Postgres page blocks run under {test=run ... backend=postgres} (F25; the five framework blocks stay compile-only); an unmarked docstring fence is refused and every prelude is cited.

Interfaces changed: the printed-output marker <!-- prints: <example>.py:<snippet> [(postgres)] --> / <!-- prints: above --> / <!-- shows: <what> -->; harness functions fenced_blocks, run_python, check_fence; helper module tests/_docs_sql.py (captured, check_block); fence setting backend=postgres.

Part of #698 (panel round 1 on PR #777)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
@0x054

0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Verdict: FIX FIRST · Round 2

Verify is red on the new head (two tests/test_step_class.py pins of the Deploying guide's old migration numbering fail on both dialects), and five new Important findings stand; Spec and Coherence pass and every Round 1 item is ADDRESSED or declared.

Earlier findings

Id Status Evidence
S1 --allow-ahead ADDRESSED docs/pages/reference/cli.md:202-203; pinned by tests/test_docs_cli_reference.py:60, fails on 00dd767
S2 captured SQL on four pages NARROWED (declared) plan **Narrowed:** line; epic Spec deltas; rest in #778
S3 filters deviation ADDRESSED plan **Deviated:** line; epic Spec deltas
S4 RLS how-to tab ADDRESSED zensical.toml:49; path kept for the two docstring links; pinned by test_the_row_level_security_split_sits_in_its_three_tabs
F1 status flags ADDRESSED src/ferro/cli/migrate.py:557; tests/test_cli_help.py:103,112 fail on 00dd767
F2 ruff format ADDRESSED 2a0205b; ruff format --check clean
F3 PoolConfig.validate ADDRESSED src/ferro/__init__.py:307, identical to f3e46f8 plus a docstring; tests/test_connection.py:495
F4 select Raises ADDRESSED models.py:794, builder.py:787; executed: unknown column → AttributeError
F5 Session.query typing ADDRESSED (kept, declared) plan Deviated; upgrading.md:72-77
F6 no-route text ADDRESSED (changed, declared) state.py:54 reads the accurate v0.14 wording (a328cfc first ships in v0.14.0); declared on PR #779; pinned tests/test_routing_errors.py:32; quoted connections.md:120, :188 says 0.14. See F31 for the upgrade-guide gap
F7 printed-output blocks ADDRESSED all 107 text blocks noted; check_printed refuses an unmarked one; the twelve error/warning texts regenerated from auto_migrate_errors.py, migration_errors.py and two new regions; one declared exception (the two ferro.sql lines). See F30 for the shows: guard
F8 entry points' Examples ADDRESSED tests/test_docstring_examples.py:98
F9 fence parsers ADDRESSED fenced_blocks (_docs_harness.py:156); three regex readers predating the finding remain (F38)
F10 subprocess runners ADDRESSED run_python (_docs_harness.py:72); six callers that need a non-zero exit remain outside it (F40)
F11 twin equivalence ADDRESSED twins generated by _docs_twins.annotated_twin; tests/test_docs_twins.py:154,168 fail on 00dd767
F12 / #776 write order ADDRESSED _bind_payload.py:150; tests/test_save_column_order.py on both dialects; pin gone; seeds 1, 2, 3, 5, 77 pass
F13 Aggregations opening ADDRESSED aggregations.md:9,15; region starts at connect
F14 SQLite caveat ADDRESSED auto-migrate.md:230
F15 default_factory idiom ADDRESSED conventions :272; generator applies it; ty clean on the four named twins
F16 _chunk_loop ADDRESSED (kept, declared) plan Deviated; upgrading.md:76
F17 CONTRIBUTING fence ADDRESSED page_fences reads --8<-- Markdown; test_docs_examples[CONTRIBUTING.md:317-334]
F18 MOVED_ANCHORS ADDRESSED six redirects; docstring narrowed; test_moved_anchors_redirect_to_existing_sections
F19 CI docs list ADDRESSED ci.yml:108 glob; test_docs_site.py:314 pins it
F20 page/module size NOT IN ROUND queries.md 1859 lines; for Learnings
F21 check_fence ADDRESSED _docs_harness.py:384
F22 capture helper module ADDRESSED tests/_docs_sql.py
F23 capture tests via interface ADDRESSED tests/test_docs_sql.py:394
F24 --help vs reference ADDRESSED tests/test_docs_cli_reference.py
F25 Postgres blocks ADDRESSED (eight run; five framework blocks compile-only) test_docs_examples_on_postgres; skips loudly without Postgres. See F27, F33, F34
C1 unmarked docstring fence ADDRESSED check_fence refuses; 19 fences marked
C2 unused prelude ADDRESSED deleted; test_every_prelude_is_cited_by_a_fence

Spec: PASS

Coherence

The slices agree on every shared interface except one minor seam:

  • Minor · src/ferro/session.py:295, :422 (and src/ferro/rowsecurity.py:5) at 8ae9aee · These docstring fences stay {test=compile reason="needs a Postgres database"} while docs: panel round 1 on #777 (harness and pages) — output blocks regenerated, one fence reader and runner, twin equivalence, CLI reference complete #780 shipped backend=postgres for docstrings and the conventions now say a block that needs Postgres runs with it; test_docstring_example_on_postgres has an empty parameter set. Folded into F34.
    Checked and consistent: the no-route text in state.py, the pin and connections.md:120; the printed-output markers documented and enforced; one fenced_blocks/page_fences/fenced_examples reader for six tests; check_fence for page and docstring fences; run_python for every script runner; tests/_docs_sql.py consumed by the SQL and output tests; backend=postgres parsed once and run by the backend-matrix job; the CI glob; _docs_twins.annotated_twin matching the F15 rule.

New findings

Critical

none

Important

  • F26 verify · tests/test_step_class.py:466-470 @ 8ae9aee · conf 100 · The Deploying guide's refusal pin uses the page's old migration numbering, so the full suite is red
    test_the_deploying_guide_quotes_the_refusal_expand_only_raises (both dialects) searches the page for a DatabaseAheadError block naming 0008_drop_bio:01; docs: panel round 1 on #777 (harness and pages) — output blocks regenerated, one fence reader and runner, twin equivalence, CLI reference complete #780 regenerated that block from migration_errors.py, which numbers it 0003_drop_bio, and the test is outside the tests/test_doc*.py glob the docs Verify runs. · The PR's Python job fails; the page is right by construction and the stale pin is the defect. · Fix: point the test at the regenerated numbering (or drop its page-quote half, since test_docs_output.py now owns that pin) and keep its runtime half. · verified by: full suite at 8ae9aee twice: 2 failed, 5548 passed; the two FAILED ids; read the test.
  • F27 adversarial · docs/pages/guide/schema/auto-migrate.md:147-151 @ 8ae9aee · conf 92 · The label-addition fence now runs on Postgres but never adds a label
    docs: panel round 1 on #777 (harness and pages) — output blocks regenerated, one fence reader and runner, twin equivalence, CLI reference complete #780 turned the connect(..., migrate_updates=True) block into {test=run prelude=auto_migrate backend=postgres}; the prelude's Author has no plan field and no Plan enum, and the fixture hands it an empty schema, so the run creates author from nothing and sends no ALTER TYPE "plan" ADD VALUE, while the next sentence says it does. · The fence counts as executed (story 4) yet passes while label addition is broken or the sentence wrong. · Fix: a prelude that declares Plan/Author with plan and builds the live type with only 'free' before the block, then shows the labels under a prints: above block; or back to compile-only with its reason. · verified by: ran the fence under the prelude runner against a fresh schema with ferro.sql at DEBUG: no ALTER TYPE; pg_type shows no enum.
  • F28 maintainability · tests/_docs_sql.py:377 @ 8ae9aee · conf 88 · Two ways to point connect() at the test database
    _docs_sql.py patches ferro.connect with its own connect_to, and docs/examples/_preludes/__init__.py has point_connect_at, the same patch driven by FERRO_DOCS_DATABASE_URL; the two fix slices each wrote one. · The redirect (which arguments pass through, when the URL is replaced) is maintained twice; a fix to one leaves SQL/prints: blocks and run fences disagreeing about the database they hit. · Fix: one redirect, importable from the harness, used by both. · verified by: read both patches.
  • F29 maintainability · tests/_docs_sql.py:159 @ 8ae9aee · conf 85 · The snippet-region grammar is parsed four times
    --8<-- [start:x]/[end:x] is parsed by _docs_sql.snippet_ranges, _migrations_project._region, test_docs_examples._MODELS_SECTION and _docs_harness._section, and the readers already differ (first-wins vs last-wins, strip, numbering). · A marker-syntax change lands four times. · Fix: one snippet_regions(text) in the harness. · verified by: read all four.
  • F30 testing+adversarial+standards-and-invariants · tests/test_docs_output.py:67-90 @ 8ae9aee · conf 88 · The shows: recogniser misses most Ferro output kinds, and VARIES exempts a whole page
    FERRO_OUTPUT matches only lines starting ferro., ferro auto-migrate:, <Name>Error|Refused|Warning:, N validation errors for or connect(; it matches none of the 56 ferro migrate output/refusal blocks, nor No database route… (connections.md:119), Cannot add NOT NULL column… (auto-migrate.md:68), a traceback line for ModelDoesNotExist/BatchFailed, or a path:N: UserWarning: line; and the VARIES exemption is keyed by page, so any shows: block on the Connections guide passes. · The guard added for F7 can be bypassed by the commonest kind of Ferro output. · Fix: test the recogniser against every real prints/output/refusal block body on the pages and widen it until it matches them all (the ferro migrate[ a-z]*: prefix, Ferro's exception names, a path:N: prefix); key VARIES by page:line. · verified by: ran FERRO_OUTPUT.search over every text block and the sample lines.
  • F31 standards-and-invariants+api-contract+history+review-pr · docs/pages/upgrading.md:72-77 @ 8ae9aee · conf 90 · The 0.26 section says "asks nothing of code that runs today" while two behaviours changed
    It lists only the typed Session.query and the _chunk_loop rename; since 0.25.0 the routeless-operation RuntimeError text changed (v0.13 → v0.14, "sessions migration guide" → "upgrade guide") and every write lists columns in field order (save(only=) / save(exclude=) emit columns in set order, so the UPDATE text changes between runs #776), which changes the statement a model with bytes or secret columns sends; tests/test_routing_errors.py:23 names the pinned text "the shipped text" with the comment "Callers match on this text", though v0.25.0 shipped the other one. · Code matching the old message or comparing captured SQL is affected and the guide says otherwise. · Fix: two more bullets in the 0.26 section; rename the test to say it pins the current wording. · verified by: git show v0.25.0:src/ferro/state.py vs head; the _bind_payload diff; the test diff.

Minor

  • F32 standards-and-invariants · docs/pages/guide/schema/deploying.md:124-152 @ 8ae9aee · conf 85 · The regenerated rolling-deploy section gives 0002_author_slug three different step lists and two migrations the number 0003
    Lines 124/131/142 call 0002_author_slug "one schema step, ADD COLUMN, nullable" (what migration_errors.py builds); lines 46-49 and 228-235 show it as a four-step expand/backfill/add_constraint/contract migration; 213-215 show a 01_schema step; line 125 names 0003_author_slug_required and 146-152 0003_drop_bio, which ferro migrate check refuses as a duplicated number. · A reader cannot tell which 0002 is meant. · Fix: make the table and prose name what the example builds, or have migration_errors.py reproduce the table's two-release chain.
  • F33 correctness+adversarial · docs/examples/_preludes/__init__.py:87-96 @ 8ae9aee · conf 85 · The backend=postgres redirect drops the URL parameters three blocks exist to show
    point_connect_at swaps the whole URL for the test database's, so connections.md:57 (sslmode=require), :298 and backends.md:88 (ferro_search_path=…) run without them; "The pool connects over TLS" is never exercised. · Fix: carry the block's own query options onto the test URL (and create the named schema), or mark these three compile-only with a reason and cover the options in a dedicated test.
  • F34 standards-and-invariants+review-pr · docs/solutions/patterns/docs-page-conventions.md:200,215 @ 8ae9aee · conf 88 · Two opposite rules for a Postgres-only block, and six fences still follow the old one
    Line 200 still gives {test=compile reason="needs a Postgres database"} as the example; line 215 says such a block runs with backend=postgres. Fences on the old rule: src/ferro/session.py:295,:422, rowsecurity.py:5, auto-migrate.md:104,:125, row-level-security.md:313; the docstring Postgres test set is empty. · Fix: change the example and move the fences (or give each a reason the new rule accepts).
  • F35 standards-and-invariants · src/ferro/rowsecurity.py:5; src/ferro/migrations/testing.py:56,:63 @ 8ae9aee · conf 82 · Two newly marked docstring fences give a reason that is untrue or not a reason
    rowsecurity.py:5 says the model's row security needs Postgres; the block cannot run because its names are never imported. testing.py:56/63 say what the block is, not why it cannot run. · Fix: "a model fragment; its imports are left out" / "a value round_trip returns; RoundTripResult is not imported here".
  • F36 standards-and-invariants+adversarial · docs/examples/migration_errors.py:61-91 @ 8ae9aee · conf 82 · Error regions print a hand-typed class path
    print(f"ferro.migrations.DatabaseAheadError: {error}") types the class name; the conventions say f"{type(exc).__name__}: {exc}" so the printed text is what Ferro raised, and a subclass would print the parent's name. · Fix: derive the name from the exception; update the three deploying.md blocks.
  • F37 adversarial · tests/_docs_sql.py:600 @ 8ae9aee · conf 85 · output:/refusal: notes are accepted on any page, but only hand-listed pages are compared
    check_printed returns for any note but prints; test_docs_migrations compares output: only on OUTPUT_PAGES and refusal: only on the CLI reference, so a typed block under either note on another page passes every gate. · Fix: refuse an output:/refusal: note outside the compared set, or derive the set from the pages that carry the note. · verified by: fabricated blocks on howto/testing.md accepted.
  • F38 maintainability · tests/test_docs_testing_fixtures.py:34 @ 8ae9aee · conf 85 · A regex fence reader (_PYTEST_OUTPUT) remains beside fenced_blocks (and test_docs_migrations.py:234,:1396).
  • F39 maintainability · tests/_docs_sql.py:54 @ 8ae9aee · conf 85 · REPO_ROOT / "docs" / "examples" rebuilt in five modules; export EXAMPLES/example_path from the harness.
  • F40 maintainability+review-pr · tests/test_docs_lint.py:20 @ 8ae9aee · conf 80 · run_python always asserts exit 0, so six callers that expect a non-zero exit keep their own subprocess.run with drifting timeouts; add check=False.
  • F41 depth · tests/_docs_sql.py:561 @ 8ae9aee · conf 82 · check_printed leaves note parsing and the dialect/run pairing to its caller; test_docs_output.py re-matches PRINTS to find the Postgres blocks. Follow-up: a selector and a database_url argument behind _docs_sql.

Invariants and ADRs

Checked: AGENTS.md (I-3, I-6, I-7, I-8, I-10, I-13, I-14), GLOSSARY.md, the two conventions pages as changed, the writing-for-humans rules, ADR-0057 (the ferro.sql lines match _sql_log.py's format), the plan's Narrowed/Deviated lines, the epic's Spec deltas, both fix PR bodies; the src/ AST diff 00dd767..8ae9aee (only the intended changes); git log/blame on _bind_payload, the restored flags and validate, the no-route text (a328cfc first in v0.14.0), the twins, the harness reader, the deleted prelude, deploying.md's numbering (nothing undone that an earlier commit promised; the stale test_step_class pin is F26).
Violations: F26 (Verify red); F31 [plan Deviated; I-6; epic Out of Scope: behaviour changes recorded]; F34 [conventions stated once]; F35 [I-14, the marker table's reason rule]; F36 [conventions: printed output regenerated]. None contradicts an ADR.
Candidate ADRs: none new.
Terminology drift: none.

Coverage

Fix diff: 00dd767..8ae9aee, 102 files (+4959 / −1120), three commits (2a0205b, PR #779 c80e3eb, PR #780 8ae9aee).
Personas: per-finding check (correctness), Spec/Coherence re-run (review-pr full-epic), composition pass: correctness, testing, maintainability, standards-and-invariants, history, depth, adversarial, api-contract.
Dropped below confidence 80: 0. Cannot verify from diff: the prose rules across untouched pages; whether the next release is 0.26 (pyproject 0.25.0, major_on_zero = false); connections.md:57's TLS claim against a TLS server; whether any external caller matched the old no-route text; CI run 39662225253 on 8ae9aee (pending at review time).
Verify: failed on 8ae9aee: full suite pytest tests --db-backends=sqlite,postgres -n 8 → 2 failed (tests/test_step_class.py::test_the_deploying_guide_quotes_the_refusal_expand_only_raises[sqlite|postgres], F26), 5548 passed, 70 skipped, 2 xfailed; the targeted blocks passed (strict build clean; tests/test_doc*.py + docstring examples 1070 passed on sqlite+postgres; _docs_checks exit 0; ruff, format, ty clean). Trial merge: not run (base unchanged).

Residual risk

…e redirect and one region parser, the shows: guard, the 0.26 notes (#782)

Round 2 panel findings on PR #777: the Deploying guide's refusal pin reads the regenerated numbering (F26, the suite is green again); the label-addition fence runs on Postgres against a live enum with only 'free' and shows ['free', 'pro'] afterwards (F27); one connect() redirect and one snippet-region parser in tests/_docs_shared.py (F28, F29); the shows: guard matches every kind of Ferro output and VARIES is keyed by page:line (F30); the 0.26 upgrade notes state the reworded no-route message and the field-order writes, with update() stated separately (F31); one name per migration number on the Deploying guide (F32); the Postgres redirect keeps ferro_search_path and the sslmode block is compile-only with a true reason (F33); one Postgres-block rule in the conventions, the session.py docstring fences run on Postgres (F34); true compile reasons (F35); error regions print the exception's own class (F36); output:/refusal: notes outside the compared set are refused (F37). F38 and F41 partly; F39 and F40 left for Learnings.

Interfaces changed: tests/_docs_shared.py (point_connect_at, snippet_regions); check_printed takes database_url.

Part of #698 (panel round 2 on PR #777)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
@0x054

0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Verdict: HUMAN DECISION · Round 3

Verify is green on f20bcca (full suite 5743 passed on sqlite+postgres), Spec and Coherence pass, and every Round 2 item is ADDRESSED or declared; three Important findings remain open after the last fix round the process allows, so the merge is the maintainer's call.

Earlier findings

Id Status Evidence
F26 suite red ADDRESSED tests/test_step_class.py:460: page half reads the regenerated prints: block, runtime half kept; full suite 5743 passed on f20bcca
F27 label-addition fence ADDRESSED _preludes/label_addition.py; probed: the block's only DDL is ALTER TYPE "plan" ADD VALUE IF NOT EXISTS 'pro', prints ['free', 'pro'], compared on Postgres (test_docs_output.py:58)
F28 two redirects ADDRESSED one point_connect_at (tests/_docs_shared.py:123); pinned test_docs_shared.py:139
F29 four region parsers ADDRESSED one snippet_regions (_docs_shared.py:45); pinned test_docs_shared.py:91
F30 shows: guard ADDRESSED (see F43) recogniser widened; test_the_recogniser_matches_every_block_of_ferro_output (:188); VARIES by page:line with a staleness test
F31 0.26 notes ADDRESSED (see F42) upgrading.md:72-81: no-route wording (old and new), field order per door, update() stated separately; test renamed
F32 deploying numbering ADDRESSED one chain: 0001_create_author, 0002_author_slug_nullable, 0003_author_slug_required; pinned test_docs_migrations.py:1499
F33 redirect drops options ADDRESSED redirected_url carries ferro_search_path and creates the schema; sslmode block compile-only, "needs a TLS-enabled server"; pinned test_docs_shared.py:133
F34 two Postgres rules ADDRESSED one rule (conventions:216); session.py:295,:422 run on Postgres (2 passed); row-level-security.md:313 runs
F35 untrue reasons ADDRESSED rowsecurity.py:5, migrations/testing.py:56,:63
F36 hand-typed class paths ADDRESSED (see F44) type(error).__name__ everywhere; blocks regenerated
F37 output:/refusal: on any page ADDRESSED _marked/_registry_gaps; pinned test_docs_migrations.py:277
F38 leftover regex readers PARTLY _PYTEST_OUTPUT and the revision-block regex remain
F39 DOCS_EXAMPLES ×5 LEFT (declared) now in eight modules
F40 run_python check=False LEFT (declared) six own subprocess.run callers remain
F41 check_printed note parsing PARTLY database_url= added; callers still re-match PRINTS
Spec Minor: (#291)/(#294) LEFT (declared) the messages' own text; #781
Spec Minor: deltas bookkeeping ADDRESSED plan Deviated and epic Spec deltas corrected by the orchestrator (update() dropped from the field-order claim)

Spec: PASS

Coherence

The slices agree on every shared interface except one minor seam:

  • Minor · tests/_docs_harness.py:251 at f20bcca · _INCLUDE reads a Markdown include's section name as [A-Za-z_-]+ while _docs_shared.SNIPPET allows digits; an include of a region whose name has a digit, or the line-range form (CHANGELOG.md:2:, on a page today), does not match and its fences are silently left unread. Harmless today. Fix: one name class, and refuse an include form the harness cannot read.
    Checked and consistent: point_connect_at the one ferro.connect patch (two consumers, pinned); snippet_regions the one region reader (five callers, identity pinned); check_printed(database_url=) one signature with consistent callers; the three new preludes each define opened() and are cited and green on Postgres; the conventions match the fences and the redirect; the no-route text agrees across state.py, its pin and connections.md; type(error).__name__ used across the printing examples.

Quality: PASS

  • Minor · tests/_docs_harness.py:251 · the include regex's name class (see Coherence).
  • F31, F32, F34, F36 resolved (details in the table above).

New findings

Critical

none

Important

  • F42 standards-and-invariants · docs/pages/upgrading.md:74-79 @ f20bcca · conf 88 · The 0.26 notes misstate what changed for secret columns
    The field-order bullet says "Before, a bytes or secret column came last" and "The rows written are the same; the statement text is not". In v0.25.0 save_bind_payload dumped a SecretStr/SecretBytes column in field order and wrote the mask **********; raw secret binding (and so "secret last") arrived in 20ee954 (SecretStr and SecretBytes fields save as '**********' and hydrate as plain str #760), after v0.25.0 and unreleased. So no 0.25 user saw a secret column last, and a 0.26 save of a secret column writes the real value where 0.25 wrote the mask, and a fetched secret field loads as SecretStr where it loaded as str. The section's "Four changes" omits this. · The upgrade guide is the page a 0.25 user trusts; it states two falsehoods about secrets and says rows are unchanged when a model with a SecretStr field now stores different values and loads a different type. · Fix: drop "or secret" from the "came last" sentence; add a fifth bullet: SecretStr/SecretBytes fields now store the real value (0.25 wrote **********) and load back as the secret type; rows saved by 0.25 hold the mask and need rewriting. · verified by: git show v0.25.0:src/ferro/_bind_payload.py (no secret handling); git log v0.25.0..f3e46f8 lists 20ee954 whose body says "(was **********)". [epic Spec deltas; I-6]
  • F43 adversarial+testing · tests/test_docs_output.py:103-124,188 @ f20bcca · conf 90 · The shows: guard still passes ferro migrate body lines, check refusals and ferro.sql records; its self-test matches per block, not per line
    FERRO_OUTPUT does not match ok 0001_create_author applied …, ok Wrote migrations/…, recorded … as baseline, default · sqlite:///./app.db (2 pending), a step row, pending 0002_x, any ferro migrate check line, the squash note, or a DEBUG:ferro.sql:… record. test_the_recogniser_matches_every_block_of_ferro_output uses FERRO_OUTPUT.search(block.body) over the whole block, so each output: block passes on its header line alone and the body lines are never tested; CLI_LINE in test_docs_migrations.py:1114 knows these lines but runs only over OUTPUT_PAGES (seven pages). · A shows: block quoting status/check/baseline output without its header, or a ferro.sql log, on any of the 25 other pages passes every gate: the F30 bypass this round claimed closed. · Fix: a per-line self-test (every non-blank line of every output:/refusal:/message prints: block must match), fold CLI_LINE's alternatives into the one recogniser, add the %(levelname)s:%(name)s: prefix. Also a floor on the block counts so an empty list cannot pass (testing's Minor). · verified by: probe over 30 real lines (13 miss); three fabricated shows: blocks on the quickstart accepted by check_printed and the guard; every self-tested block matched on its first line only.
  • F44 adversarial+standards-and-invariants · docs/pages/guide/schema/overview.md:47 @ f20bcca · conf 88 · The type(error).__name__ fix prints RunRefused, a class no page documents and ferro.migrations does not export
    migration_errors.py:108 catches ferro.migrations.MigrationRefused and prints the raised class's name, ferro.migrations.report.RunRefused; it is not in ferro.migrations.__all__, not an attribute of ferro.migrations or ferro, on no page, and the exceptions reference names MigrationRefused and its public subclasses only. The recogniser accepts it because _ferro_exception_names walks every subclass whose module starts with ferro, internal ones included. · A reader who copies the name into except RunRefused: gets a NameError; the page contradicts the exception reference. · Fix: export and document RunRefused (it carries kind, which callers are told to match on), or print the nearest public base, and have the recogniser accept only public names. · verified by: hasattr(ferro.migrations, 'RunRefused') False; MRO RunRefused → MigrationRefused → FerroError; grep of docs/pages.

Minor

  • F45 correctness · docs/examples/_preludes/label_addition.py:38 @ f20bcca · conf 80 · The prelude fails on a second run against the same schema (CREATE TYPE "plan" with no guard; ADD VALUE cannot be rolled back); the suite's fresh schema per test hides it; a hand re-run with a fixed FERRO_DOCS_DATABASE_URL fails without saying why. Fix: DROP TABLE/TYPE IF EXISTS before and after, or say the prelude needs an empty schema.
  • F46 standards-and-invariants · docs/examples/enum_labels.py:6 (and the twin) @ f20bcca · conf 95 · The docstring names docs/examples/_preludes/enum_labels.py; the prelude is label_addition.py.
  • F47 standards-and-invariants · docs/solutions/patterns/docs-page-conventions.md:199-216 @ f20bcca · conf 80 · The compile-only rule is stated twice with different lists ("model fragments whose imports are left out" at :201 vs "cannot run anywhere the suite can" at :216); rowsecurity.py:5 leans on the looser one.
  • F48 testing · tests/test_docs_output.py:166 @ f20bcca · conf 82 · The recogniser pin and the Postgres-block comparison are parametrized over collected lists with no floor; an empty list passes or skips with nothing compared (81 and 4 blocks today).
  • F49 testing · tests/test_docs_cli_reference.py:62 @ f20bcca · conf 85 · A flag counts as listed if it appears anywhere in the verb's section, prose included (Round 2 advisory, unchanged).
  • F50 maintainability · tests/_docs_harness.py:51 @ f20bcca · conf 82 · SnippetRegion and redirected_url re-exported under noqa: F401 with no importer; snippet_regions/point_connect_at have two import paths (depth agrees: two doors to _docs_shared).
  • F51 maintainability · docs/solutions/patterns/docs-page-conventions.md:9,109,186 @ f20bcca · conf 85 · related_files omits tests/_docs_shared.py; the shows: paragraph still lists five output kinds and a page-keyed VARIES; the step-file example names 0002_author_slug/04_contract.up.sqlite.sql, a file the chain no longer has.
  • F52 depth · tests/test_docs_output.py:58 @ f20bcca · conf 80 · What a prints: note resolves to is decided in check_printed, _on_postgres and _shows_a_message; check_printed(database_url=) has the caller pass the URL twice (run lambda and argument) plus dialect; the three new preludes have three opened() contracts (no connection/no session; connects/no session; connects/no session) while the runner docstring describes one.
  • F53 standards-and-invariants (advisory raised) · docs/examples/_preludes/label_addition.py:23 @ f20bcca · conf 80 · The prelude hand-types "what the create pass sends" for the free-only Author; nothing pins it against the create pass.

Invariants and ADRs

Checked: AGENTS.md (I-1, I-6, I-7, I-8, I-10, I-14), GLOSSARY.md, both conventions pages as changed by this round, the writing-for-humans rules, ADR-0057 (the ferro.sql lines), the plan's Narrowed/Deviated lines, the epic's Spec deltas, PR #782's body; git log/blame on the refusal test, the deploying chain, the label-addition sentence, the sslmode block, the seven <Class>: blocks, the region readers and the session.py fences (nothing undone that an earlier commit chose on purpose); src/ unchanged this round apart from three docstring markers.
Violations: F42 [epic Spec deltas; I-6: an understated behaviour change]; F44 [I-14: a printed name the reader cannot follow]; F47 [conventions stated once]. None contradicts an ADR; no code violation.
Candidate ADRs: none new.
Terminology drift: none.

Coverage

Fix diff: 8ae9aee..f20bcca, 38 files (+1086 / −238), one squash commit (PR #782).
Personas: per-finding check (correctness), Spec/Coherence re-run (review-pr full-epic), composition pass: correctness, testing, maintainability, standards-and-invariants, history, depth, adversarial (api-contract dropped: no src/ signature changed; security/data-migration not applicable).
Dropped below confidence 80: 0. Cannot verify from diff: concurrent suites against one Postgres server sharing the fixed tenant_a/test_42 schemas; a schema leaked by a killed prelude subprocess; connections.md:57's TLS claim (compile-only by design); the prose rules across untouched pages.
Verify: passed on f20bcca (full suite pytest tests --db-backends=sqlite,postgres -n 8 → 5743 passed, 69 skipped, 2 xfailed; strict build clean; docs set + test_step_class + test_routing_errors + test_save_column_order 1315 passed on sqlite+postgres; _docs_checks exit 0; ruff, format, ty clean). Trial merge: not run (base unchanged).

Residual risk

  • The docs CI job runs on SQLite only; every backend=postgres fence and Postgres prints: block runs in the backend-matrix job under postgres_only.
  • redirected_url lets a block's ferro_search_path name a fixed shared schema of the test database (tenant_a, test_42); a schema left by a killed run is reused, never dropped.
  • VARIES exempts the whole connections.md:333 block, not its lines (a block inserted above it fails loudly).
  • save()'s column order for a model with bytes or secret columns changed; SecretStr columns now store real values (see F42 for the upgrade note).
  • deploying.md:152's "release 1 code writes slug on every save" holds only if the application sets it; a release-1 save that leaves it None is the late row the status example fails on.

…and documented (panel Round 3 F42, F44) (#784)

Two findings from the panel's Round 3 report on #777, fixed before
merge.

## F42 · the 0.26 notes misstated the secret-column change

`docs/pages/upgrading.md`, "Upgrading to 0.26":

- The field-order bullet no longer says a secret column came last. In
0.25 a `SecretStr`/`SecretBytes` column was dumped in field order, as
the mask. "The rows written are the same" is now "The order changes no
value written". `update()`'s clause names only `bytes`.
- A fifth bullet states what changed for secrets: the real value is
stored and loads back as `SecretStr`/`SecretBytes`, where 0.25 wrote
`**********` (on `save()`, `bulk_create()` and `update()`) and loaded
the `str` `**********`. A row 0.25 saved holds the mask and cannot be
recovered. A `SecretBytes` column is now `BYTEA`/`BLOB`, where 0.25
created `varchar`, and it needs a migration before 0.26 saves into it.
- The count is now "Five changes".

I checked one fact by hand, and it differs from #762's body. On this
branch a `SecretBytes` field over a 0.25 `varchar` column **loads
without error** on SQLite and on Postgres; I saw no `Failed to hydrate`.
The real hazard is the write. On Postgres, `save()` of
`SecretBytes(b"\xff\x00k")` into the `varchar` column stores the text
`\xff006b`, and that text loads back as the bytes `b"\\xff006b"`. The
bullet says exactly that. On SQLite the value round-trips.

Covering test:
`tests/test_save_column_order.py::test_the_upgrade_guide_states_the_secret_column_change`.
It checks that the order bullet has no "secret column came last" and
that the section opens with "Five changes". It also checks that the
secret bullet names `SecretBytes`, `**********`, "cannot be recovered",
`varchar`, `BYTEA`, `BLOB` and "migration".

```
$ uv run --no-sync pytest tests/test_save_column_order.py -k upgrade --no-cov -p no:cacheprovider
RED  (before the page edit): 1 failed, 1 passed
GREEN:                       2 passed
```

## F44 · the Overview printed `RunRefused`, a class nothing exported

- `ferro.migrations.RunRefused` is exported (`__all__`). It appears in
the API migrations page as `::: ferro.migrations.RunRefused` and in the
exceptions tree, between `MigrationRefused` and its two subclasses
`AlreadyTrackedError` and `AppliedAboveBaselineError`.
- Its docstring (in `report.py`, docstring only) now says that
`connect(auto_migrate=True)` also raises it and that `except
MigrationRefused` catches it. It gains an `Examples:` block that matches
on `kind`. `kind` keeps its attribute docstring. I did not add an
`Attributes:` section: every class in the package documents fields that
way, and a section would render `kind` twice.
- `overview.md` now says, after the block, that the error is a
`RunRefused`, a subclass of `MigrationRefused`, so `except
ferro.migrations.MigrationRefused` catches it. It also says that `kind`
names a run's refusal and is `None` for this one. I checked: this
refusal's `kind` is `None`, so "whose `kind` names the refusal" would
have been wrong here.
- `tests/test_docs_output.py`: `_ferro_exception_names` now lists only
public classes. A public class is one in `ferro.__all__` or
`ferro.migrations.__all__`, or a public class of `ferro.exceptions`. A
new check, `test_a_printed_exception_name_is_one_a_reader_can_import`,
runs over every text block and fails a line that opens with an internal
Ferro exception class.
`test_a_block_naming_an_internal_exception_class_fails` pins the check
with `StepRefused`, bare and dotted, and
`test_the_recogniser_names_only_public_exception_classes` pins the list.

```
$ uv run --no-sync pytest tests/test_docs_output.py -n 8 --no-cov -p no:cacheprovider
RED  (before the export): 1 failed (guide/schema/overview.md:46 prints ['RunRefused']), 331 passed
GREEN:                    all passed
```

Interfaces changed: ferro.migrations.RunRefused is exported (new public
name; the class existed and was raised before)

## Verify (on 640726c)

| Command | Result |
|---|---|
| `uv run --no-sync zensical build --clean --strict` | No issues found |
| `pytest tests/test_doc*.py tests/test_docstring_examples.py
tests/test_routing_errors.py tests/test_save_column_order.py
tests/test_step_class.py -n 8 --db-backends=sqlite,postgres` | 1429
passed |
| `python -m tests._docs_checks docs/pages docs/examples src/ferro` |
exit 0 |
| `ruff check src/ferro tests docs/examples && ruff format --check .` |
clean |
| `pytest tests --db-backends=sqlite,postgres -n 8` | 5857 passed, 69
skipped, 2 xfailed |

Verify block corrected: no

## For the reviewer

- On Postgres, a `SecretBytes` value saved into a 0.25 `varchar` column
is stored as hex text and loads back wrong, with no error. The upgrade
note warns about it. The code may deserve its own issue; I left `src/`
alone here.
- `src/ferro/migrations/errors.py`'s module docstring still says
`RunRefused` lives "beside it in `ferro.migrations.report`". That file
is outside this slice, and the sentence still holds.

- [x] Issue status: no issue closes here; this is part of #698.

Part of #698 (panel Round 3 on PR #777)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@0x054

0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Post-Round 3 fix merged

F42 and F44 from the Round 3 report are fixed in #784, squash-merged as 0b72ba5 after one fresh review (Spec FAIL on S1, the kind overclaim, fixed in one round; re-review ADDRESSED, nothing new). F43 and the Minors are #783. A code-side finding from the fix is #785.

Verify on 0b72ba5 (feat/docs-rewrite): zensical build --clean --strict clean; tests._docs_checks exit 0; ruff check and format clean; pytest tests --db-backends=sqlite,postgres: 5858 passed, 69 skipped, 2 xfailed.

Interfaces changed since Round 3: ferro.migrations.RunRefused is exported (the class existed and was raised before).

Ready to merge.

@0x054
0x054 merged commit 23c6caf into main Oct 21, 2026
8 checks passed
@0x054
0x054 deleted the feat/docs-rewrite branch October 21, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docs rewrite: every snippet runs, every fact matches the code, and the API reference renders cleanly

1 participant