Repository navigation
Schema management pages: fix stale facts and pin every quoted generated step file #713
Description
Parent
What to build
Three generated-SQL blocks are stale and nothing tests them: the 0001 CREATE TABLE in the migrations guide leaves out email and uq_author_email, and the SQLite rebuild block (quoted twice) leaves out the sqlite_sequence statements and the recreated idx_author_name. The migrations guide says auto-migrate ignores rename hints, contradicting the overview, the auto-migrate page and CONTEXT.md. The adopting how-to creates 0001_initial but its outputs say 0001_create_author.
tests/test_docs_migrations.py gains a check that regenerates every quoted step file (a block headed -- NNNN_<name>/<file>) from the page's example models in a scratch project and compares it after normalising run-dependent parts, the way it already pins CLI output. The schema pages' facts are fixed against the generator, the overview's "Where the old page went" table goes (the site config ticket (#701)'s redirects replace it), and headings, internal references (ADR numbers, "epic", the AGENTS.md pins paragraph) and dated notes are cleaned up. Splitting the migrations guide is the next ticket.
Pages: guide/schema/overview.md, guide/schema/auto-migrate.md, guide/schema/data-steps.md, guide/schema/deploying.md, guide/schema/testing.md, guide/schema/alembic.md, howto/adopting-migrations.md, guide/schema/migrations.md (stale blocks and the rename-hint claim only)
Acceptance criteria
- Every fact on the page matches the code at the time of the PR; each High finding in the audit rows below is fixed, and each Medium/Low finding is fixed or answered in the PR body with the reason it was left.
- Each page is one kind (tutorial, how-to, reference or explanation) and opens with one sentence saying what it covers, then an example on the first screen (writing-for-humans rules 1–2).
- Each page uses one running example, declared once near the top and reused; each section shows only what changed (rule 3).
- Every code block has a lead-in sentence ending in a colon and a result sentence after it, and shows its result: printed output, or the asserted value inside the embedded snippet range (rules 7–8). Snippet
end:markers sit below the assert or print that holds the result. - Every copyable block either shows the
async with engines.session():wrapper or sits under a sentence that says it runs inside one and links to Connections › Sessions; the page's first block shows the wrapper. - Every inline Python block that does real work runs under the harness (
{test=run prelude=<name>}) or moves into an example script; a block left compile-only says why in its fence marker. - Every lambda passed to a query method names its parameter after the model in lowercase singular (AGENTS.md I-8);
python -m tests._docs_checksreports no violation for the owned files. - Every error check in an owned example fails when the expected error is not raised (
try/except/else: raise AssertionError, or the harness helper). - Field-declaring examples show both declaration styles as tabs, each backed by a runnable script (AGENTS.md I-7), and the twins use the one Annotated idiom recorded in the docs conventions.
- Headings are short sentence-case noun phrases naming the feature (rule 5); how-to titles name the goal (rule 24); no minimising words (rule 17); admonitions follow rule 20.
- No ADR numbers, issue numbers, codenames, dated or release-bound notes, or contributor-only references in page text; version markers are self-contained blocks at the end of a section.
- Reference material that has a home in Reference is linked, not repeated; each topic ends with a pointer to its reference entry (rule 4).
Files owned
- Modify:
docs/pages/guide/schema/overview.md,docs/pages/guide/schema/auto-migrate.md,docs/pages/guide/schema/data-steps.md,docs/pages/guide/schema/deploying.md,docs/pages/guide/schema/testing.md,docs/pages/guide/schema/alembic.md,docs/pages/guide/schema/migrations.md,docs/pages/howto/adopting-migrations.md,docs/examples/migrations_adopting.py,docs/examples/migrations_backfill.py,docs/examples/migrations_testing.py,docs/examples/migrations_backfill_annotated.py,docs/examples/migrations_testing_annotated.py - Test:
tests/test_docs_migrations.py
Interfaces
- Consumes: docs conventions (AGENTS.md ticket (AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703))
- Consumes: harness (harness ticket (Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704))
- Consumes: redirects (site config ticket (Docs site config: Zensical features, Markdown extensions, API reference options and a strict build #701))
- Produces: Step-file pinning in
tests/test_docs_migrations.py: every Markdown block whose first line is-- NNNN_<name>/<file>is compared with a fresh generation. Used by the migrations-split ticket (Split the migrations guide: tutorial loop, how-tos, a squash page, and reference tables in Reference #714).
Test scenarios
- Happy path: the strict site build succeeds and every snippet range on the owned pages renders without leading indentation.
- Happy path: every owned example script runs to completion under
tests/test_docs_examples.py, and every inline block marked{test=run ...}runs. - Error path: deleting the
raisean owned example's error check expects makes that example's test fail. - Edge case:
python -m tests._docs_checksover the owned files exits 0; renaming one lambda parameter totmakes it exit 1 naming the file and line. - Integration: editing one statement in a quoted step file on a page makes
tests/test_docs_migrations.pyfail naming the page and block.
Verify
uv run --no-sync zensical build --clean --strict
uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
uv run --no-sync pytest tests/test_docs_migrations.py -q --no-cov -p no:cacheprovider -n 8
uv run --no-sync python -m tests._docs_checks docs/pages/guide/schema docs/pages/howto/adopting-migrations.mdBlocked by
- Docs site config: Zensical features, Markdown extensions, API reference options and a strict build #701, AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703, Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704
Audit findings
Rows from the docs audit this ticket covers (file, line, rule, fix)
docs/pages/guide/schema/overview.md
- Kind (rule 1): explanation (landing page: the two doors, the ladder, the one-door rule), with two reference matrices.
- Running example (rule 3):
Authorgainsnickname(frommigrations_quickstart.py). It is introduced in the first screen and shown in both declaration styles. - Verdict: A good landing page. It opens with what the thing is and shows an example inside the first screen, and the door matrix is the right thing to put here. It is let down by a leftover migration-map section and a coverage row that loses a SQLite nuance. B
- Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 81–90 | 1, 6 | "Where the old page went" is a transition note about the docs' own history, not about the product. It will rot, and it spends a heading on nothing a reader came for. | Delete it. Handle old URLs with redirects (zensical/mkdocs redirect_maps) instead. |
| M | 67–68 | — (fact) | The Alembic-bridge column says "# ferro: data-dependent" for adding a required column and for making one required. alembic.md:103,124 says that on SQLite the add is refused at autogenerate. The matrix hides that. |
"Postgres: # ferro: data-dependent; SQLite: ❌ refused at autogenerate". |
| L | 1, 92 | 5 | The headings "Schema Management" and "See Also" are title case. (This repeats on every page: see Cross-cutting.) | "Schema management", "See also". |
| L | 24, 73 | 5 | "The ladder" is a metaphor and "Choosing" is a gerund. Neither names the feature. | "Auto-migrate flags and migrations, compared", or keep "The ladder" but put the API in it: "The ladder: auto_migrate, migrate_updates, migrations". "Choosing a door". |
| L | 41–43 | 7 | The refusal text block has a lead-in but no result sentence. The parenthetical on line 45 is about main, not about what happened. |
Add: "Nothing has run; the message names the two ways out." |
docs/pages/guide/schema/auto-migrate.md
- Kind (rule 1): mixed. It holds a how-to (turn on each flag), a large reference matrix (lines 29–57, 29 rows), reference API docs (
PassReportfields and every warningkind), and explanation (label-addition contract, safety). - Running example (rule 3): none consistent.
Author.slug/Author.bio/Feed.provider(Providerenum) /ck_author_kind/author.slugdefault all appear, and the PassReport section redeclaresAuthorfrom scratch. - Verdict: The facts are accurate: the warning kinds match
ReportKindand the quoted texts match the source. The page, though, is roughly half reference and half how-to. The 29-row backend matrix and thePassReportfield and kind lists belong in reference, with this page linking to them. C - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 27–57 | 1, 4 | The 29-row "what migrate_updates covers" table is reference material sitting in a guide. It also overlaps overview.md "What each door covers" (lines 58–71) row for row. |
Move the full matrix to a reference page (reference/auto-migrate.md or a section of reference/configuration.md). Keep the 5–6 common rows here and end with "Every change, per backend: [reference]". |
| M | 195–257 | 1, 21 | The PassReport section is API reference: field list, the role values, and 23 kind names. It duplicates the docstrings in src/ferro/pass_report.py, which mkdocstrings can render. |
Keep the example (227–233) and the failure-.report example. Link the field and kind lists to api/ (::: ferro.PassReport). |
| M | 59–72 | 15 | "Rules worth knowing" bullets run long. Line 67 is about 160 words covering NOT NULL, Json defaults, default_factory freezing, Postgres vs SQLite DROP DEFAULT, and the migration rebuild. |
Split it into sub-bullets, or give the Json default_factory gotcha its own short !!! warning, because it freezes one UUID onto every row (data hazard). |
| M | 9–13, 226 | 7 | Code blocks have no lead-in: line 9 follows the heading directly, and line 226 (the ferro.migrate() call) follows the tabs with no sentence. |
Line 9: "Pass auto_migrate=True to connect():". Line 226: "Run the pass and read what it did:". |
| M | 205–223 | 3 | The PassReport section introduces a new Author (id, name, slug) that is unrelated to the overview's Author (id, name, email, nickname). |
Reuse the overview's model and show only the added slug line (rule 3: show only what changed). |
| M | 267 | 16, fact clarity | ""0" waits without a limit" is correct for the setting, but the --lock-timeout flag's 0 refuses at once (deploying.md:197, CLI help). The page says "the same wait ferro migrate up gives", which invites the reader to conflate the two. |
"...(default 30s; the setting's "0" waits without a limit, unlike the CLI flag's 0, which refuses at once)". |
| L | 19, 86, 154, 51 | 20 | The version markers "Added in 0.11.0." and "0.18.0+" are inline at the top of a section and inside a table cell. | Move them to self-contained !!! note "Added in 0.11.0" lines at the bottom of each section, or drop them: 0.11 is now 14 minors old. |
| L | 88, 279 | 20 | !!! danger is used for an invisible test gap (88) and for data loss (279). The rulebook has Note/Warning/Tip, and Warning is the one meant for data loss. |
Use !!! warning for both. Retitle 88 to a noun phrase: "Existing enum types without migrate_updates". |
| L | 55 | fact (wording) | "✅ RENAME TABLE" is not the SQL either dialect runs (ALTER TABLE ... RENAME TO). The same wording appears in overview.md:64. |
"✅ ALTER TABLE … RENAME TO". |
| L | 71 | 16 | "fine for a development flag, but worth knowing" is a hedge with no condition. | Cut it: "Postgres type changes take an exclusive lock and fail the connect if existing data does not cast cleanly." |
| L | 76, 82 | 22 | "(ADR-0014)" is an internal reference in a user guide. | Drop it, or link it as "why". |
| L | 74 | 5 | The heading "SQLite: warn and skip" is a slogan. | "SQLite changes that need a table rebuild". |
| L | 259 | 5 | "Two processes at once" names no feature. | "Concurrent passes and the run lock". |
| L | 189 | 22 | ferro.migrate(updates=...) exists (signature (using, updates=True, destructive=False)) but is never shown, so a reader cannot find out how to get a create-only pass from migrate(). |
Mention updates=False, or say that create_tables() is the create-only form (line 193 half-says this). |
docs/pages/guide/schema/data-steps.md
- Kind (rule 1): mostly how-to (write a backfill, choose a guard, recover from a late row), with two reference tables (declarations,
ctxmembers) and explanation (historical models). - Running example (rule 3):
Authorgains a requiredslug, carried from first screen to last. Only "Removing an enum label" switches, toOrder.status. - Verdict: The best page in the section. It has one running example, shows real output at each stop (pinned by tests), and orders sections from the common case to the specialised one. The one real defect is the stale SQLite contract block. B
- Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 69–81 | 9, fact | The SQLite contract block is not what ferro migrate new author_slug writes for this page's own model. It is missing the two sqlite_sequence statements and the trailing CREATE INDEX IF NOT EXISTS "idx_author_name" ON "author" ("name"); (name is Field(index=True) in migrations_backfill.py). |
Paste the generated file. Pin it with a test. |
| M | 145–154, 156, 202 | 7 | Three blocks have no lead-in ending in a colon: the side-by-side tree, the guard file, and the @chunked example. |
145: "The two migrations side by side:". 156: "The guard step --no-backfill writes:". 202: "The backfill above, written:". |
| M | 113–115, 249–251, 263–265 | 7 | Each of these places has two lead-ins in a row ("…naming the file and line:" then "ferro migrate up prints:"). |
Merge each pair into one: "Until you replace the todo(...), ferro migrate up refuses the migration before running any of it, naming the file and line:". |
| L | 181, 230, 235 | 22 | Internal ADR numbers appear in user prose: "(step numbers stay dense either way, ADR-0037)", "(ADR-0004)", "(ADR-0035)". | Drop them, or link them as "why". |
| L | 1 | 5 | "Data Steps and Backfills" is title case. | "Data steps and backfills". |
| L | 247 | 5 | The heading "When a late row arrives" is a clause. | "Late rows and contract recovery". |
| L | 291–307 | 3, 8 | "Removing an enum label" shows the backfill but not the Postgres contract SQL it describes in prose (ALTER COLUMN … TYPE "orderstatus_new" USING …). |
Show the generated 04_contract.up.postgres.sql (rule 13: show the SQL). |
docs/pages/guide/schema/deploying.md
- Kind (rule 1): mixed. How-to (CI, pre-commit, server, start-up apps, concurrent pre-deploys), explanation (two-release contract, step classes), reference (expand/contract table,
statusexit codes, edited-file table, lock semantics), and a dated release-upgrade note. - Running example (rule 3):
0002_author_slug, which continues data-steps' example. Good. The rolling-deploy section renumbers it to0008_author_slug/0009_…/0008_drop_bio. - Verdict: Accurate (the error texts match
errors.pyandrun_plan.rs, and the API signatures match), with good output blocks. However, the dated "Upgrading to the release withsquash" section is a release note, and line 160 is a 250-word paragraph that mixes compatibility history with guidance. C - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 162–182 | 1 | "Upgrading to the release with squash" is marked "Written 2026-10-18, for the first release after 0.24." That release is now out: pyproject.toml is 0.25.0. The section is a time-bound upgrade note inside a guide. |
Name the version ("Upgrading from 0.24 to 0.25"). Move it to an upgrade-notes page (or the GitHub release body) and leave a one-line Tip link here. |
| M | 182 | 8 | "list which ir.json files are still version 1 or 2 and which carry replaces" gives no command, although the SQL check just above it does. |
Give the command: e.g. grep -l '"ir_version": 3' migrations/*/ir.json and grep -l '"replaces"' migrations/*/ir.json. |
| M | 160 | 15, 1 | A single paragraph of about 250 words covers 0.22 records without a class, regenerate failures, which verbs warn, the rollback-ability of the step-class upgrade, a squash pointer, the one-migration shape, and late rows. | Split it. Put the 0.22/upgrade material in a !!! note "Databases last migrated by 0.22". Make "the one-migration shape is for deploys where writers stop" its own short paragraph near the release table. |
| M | 145–152 | 1 | The expand/contract classification table is reference material. | Keep it, but say once where the canonical list lives (CONTEXT's Step class is not user-facing; reference/cli.md or the API page should own it). |
| M | 228–236, migrations.md 358–362 | 1 | The status exit codes are given twice, here and in migrations.md "Checking", as well as in the CLI reference. |
Keep them in the CLI reference. Here, say "exits 3 when pending, 4 when it needs a person (exit codes)". |
| L | 109 | 21 | "Both take settings= and database= … and using=" is incomplete. Both also take url= and allow_ahead=, and up() takes lock_timeout=, progress= and on_event=. |
Link to the API reference rather than listing parameters partially. |
| L | 1, 282 | 5 | "Deploying Migrations" and "See Also" are title case. | Sentence case. |
| L | 197 | 22 | "(ADR-0029)", and ADR-0040 at line 109. | Drop them, or link them. |
| L | 62–66, 123–126, 137–141 | 8 | The Python exception texts are not <!-- output -->-pinned the way the CLI blocks are. They match the source today, but nothing keeps them matching. |
Add them to the pinned output registry in tests/test_docs_migrations.py. |
| L | 72 | 14 | "When that database is being [adopted]": fine per CONTEXT (adoption = baseline cutover). Noted only so a later editor does not "fix" it to "inherited". | — |
docs/pages/guide/schema/testing.md
- Kind (rule 1): how-to (write a migration test), plus a reference table of harness calls.
- Running example (rule 3):
Authorgains a requiredslug(0002_author_slug), the same as data-steps. Good. - Verdict: Short, focused, and correct: the
harness()signature and methods, andRoundTripResultfields, all match. It does not say that the async tests need an async pytest plugin, and its round-trip sample renames the running example's migration. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 28–41 | 9 | The tests are async def, but the page never says they need pytest-asyncio (or anyio) in auto mode, so a reader who copies them sees the tests skipped or erroring. |
Add a sentence plus a snippet: "The tests are async; with pytest-asyncio, set asyncio_mode = "auto" in [tool.pytest.ini_options]", or link the Testing how-to section that sets it. |
| M | 69–75 | 3, 10 | The RoundTripResult sample uses 0002_add_slug, but the running example is 0002_author_slug. |
Use 0002_author_slug. |
| L | 62 | 7 | The round-trip block has no lead-in. | "Walk the whole lineage in one call:". |
| L | 8 | 8 | No pytest output is shown, either for a passing run or for a backfill that is wrong. | Optional: show the assertion failure a wrong backfill produces, which proves the harness catches it. |
| L | 47–56 | 1 | The harness call table repeats api/migrations.md#the-test-harness. |
Acceptable as a compact how-to table. Link each call name to its API entry (rule 22). |
| L | 1, 83 | 5 | "Testing Migrations" and "See Also" are title case. | Sentence case. |
| L | 81 | 22 | "(ADR-0045)". | Drop it, or link it. |
docs/pages/guide/schema/alembic.md
- Kind (rule 1): how-to (install, init, configure
env.py), explanation ("One decider"), and reference (refusal table). - Running example (rule 3): none.
env.pyis generic, the marker example usescard,legacyandsize, and the bash block says-m "add nickname"but never shows the revision. - Verdict: Correct setup instructions; the
ferro_optionsandget_metadatasignatures match. However, one sentence contradicts the next paragraph, and the page never shows what a generated revision looks like for the section's ownAuthor.nicknamechange. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 78 vs 82 | fact | Line 78 says "a non-empty one holds exactly what connect(url, migrate_destructive=True) would have done". Line 82 then says "One change goes beyond the pass: deleting a model drops its live table". overview.md:66 also says auto-migrate never drops a table. |
"…holds what connect(url, migrate_destructive=True) would have done, plus one thing the pass never does: …". Fold line 82 into that sentence. |
| M | 84–89 | 8, 3, 13 | alembic revision --autogenerate -m "add nickname" is shown, but the revision it writes is not. That revision is the product of the page. |
Show the generated upgrade() / downgrade() for Author.nickname (op.add_column(...), op.create_index("idx_author_nickname", ...)), and pin it with a test. |
| M | 95–101 | 3, 9 | The marker example uses card, size and legacy, which belong to no other page, and elides the real comment with "…". The real text is # ferro: data-dependent (fails while card has rows; ferro migrations generate the backfill: \ferro migrate new`)(crates/ferro-migrate/src/revision.rs:16`). |
Useauthor(slugrequired,bio\ dropped) and print the full marker text. |
| L | 9, 17, 84 | 7 | Three code blocks have no lead-in sentence. | "Install the extra:", "Scaffold an Alembic environment outside migrations/:", "Generate and apply a revision:". |
| L | 76 | 5 | "One decider" is internal jargon. | "How the bridge plans a revision". |
| L | 111 | 22 | "(ADR-0011)". | Drop it, or link it. |
| L | 113 | 8 | "A label removed is a change existing rows need a value for…" does not say what the bridge itself does: warn, refuse, or write nothing. overview.md:69 says "❌ not written". |
State it: "The bridge does not write it; the revision is empty for that change and …". |
| L | 133 | 5 | "See Also" is title case. | "See also". |
docs/pages/guide/schema/migrations.md
- Kind (rule 1): mixed. It holds a tutorial-ish loop (init → new → up), reference (header table,
check/status/driftexit codes,--replacerules, squash rules), and several how-tos (renames, retire column, retire label, hand-written steps, two branches, squash, change a PK). At 664 lines it is the longest page in the section. - Running example (rule 3):
Author+nicknamethrough "Reading a migration". It then drifts toWriter/handle/Status(renames),Author.bio(retire),Order/Status(retire label),0002_add_x/add_ywithbio/nick(branches),Team/tmp_code(squash),Author.handle/bio(squash rename), andWriter/Book(PK). - Verdict: The content is strong and the CLI output is pinned by tests. However, two SQL blocks are stale against the generator, one sentence contradicts the overview and auto-migrate pages on table renames, and the page holds five how-tos that each deserve their own page. C
- Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 214 | fact | "Auto-migrate honours the column and label hints but not a table's: its create pass builds the new table name as a new, empty table." This is stale. overview.md:64, auto-migrate.md:55 and CONTEXT.md (Create pass: "a table a live rename hint says already exists under its old name is not missing, so the pass holds it back") all say the pass renames under migrate_updates and otherwise warns and creates nothing. |
"Auto-migrate honours every hint under migrate_updates. Without it, a table hint makes the create pass hold the table back with a warning rather than create an empty one (what each door covers)." |
| H | 112–121 | 9, fact | The 0001_create_author SQL is stale. The running example's 0001 model has email: str = Field(unique=True) (migrations_quickstart.py, tests/test_docs_migrations.py:AUTHOR). The generator actually writes CREATE TABLE IF NOT EXISTS "author" ( "email" varchar NOT NULL, "id" serial PRIMARY KEY NOT NULL, "name" varchar NOT NULL ); plus CREATE UNIQUE INDEX IF NOT EXISTS "uq_author_email" ON "author" ("email"); (SQLite likewise). The page shows id, name only, in a different column order and with no unique index. The block is not pinned by any test. |
Paste the real files. Then add a test, as the <!-- output: --> blocks have, that regenerates every -- NNNN_…/…sql block the guide quotes and compares it byte for byte. |
| H | 327–339 | 9, fact | The SQLite rebuild block (0002_author_slug/04_contract.up.sqlite.sql) is not what the generator writes. The real file also has DELETE FROM sqlite_sequence … / INSERT INTO sqlite_sequence … (which keep the AUTOINCREMENT counter) and recreates CREATE INDEX IF NOT EXISTS "idx_author_name" …, because the backfill example's name has index=True. The page says the rebuild "recreates the table and its indexes", yet the block shows no index. The same stale block is duplicated in data-steps.md:69–81. |
Paste the real file. Show the rebuild once (on data-steps or here) and link to it from the other page. |
| M | 598 | 1 | "Two pins in tests/test_cross_emitter_parity.py hold a squash to this page … (h) … (i)", with links into AGENTS.md. This is contributor-internal material in a user guide. |
Delete it. If a guarantee belongs here, state it as product behaviour: "A squash writes exactly the files new would write across the range." |
| M | 458–598 | 1, 5 | The squash section (about 140 lines, four subsections) is a full how-to inside a page that is already a how-to collection. The heading "Squashing an epic's migrations" uses the project's internal SDD word "epic". The subheadings "A squash is pre-release" and "Every database that applied the originals inherits the squash" are sentences, not names. | Move it to its own page, guide/schema/squash.md ("Squashing migrations"). Use "a feature branch's migrations". Headings: "Squash timing", "Inheritance on databases that ran the originals", "Renames inside the range", "squash vs --replace". |
| M | 1–664 | 1 | The page splits by topic, not by kind. Renames, retiring a column or label, two branches, squash and changing a PK are each how-tos. Headers, check/status/drift exit codes and --replace rules are reference. |
Keep migrations.md as the loop: init, new, read, up, check, down. Split out a "Renames and retirement" page (renames, retire column, retire label: all rolling-deploy shapes), "Branches and squashing" (--replace, squash), and move "Changing a primary key" to howto/. Move the header table and exit codes to reference/cli.md, linking there instead. |
| M | 13, 296, 307, 413, 469, 604 | 7, 22 | Commands are written as a backticked paragraph (ferro migrate init --models …) rather than a fenced bash block. On line 13 the bare command line is the lead-in itself, which is not a sentence ending in a colon. |
"Pass every answer as a flag and init asks nothing:", then a ````bashblock with the command, then thetext` output block. |
| M | 7–9, 92 | 7 | The install block and the directory-tree block have no lead-in sentence. | Line 7: "Install the CLI extra:". Line 92: "new writes one directory per migration:". |
| M | 400–431 | 10, 3 | The two-branches example uses placeholder names add_x / add_y / nick that carry no meaning, while the page's own example is Author.nickname. |
Use 0002_author_bio and 0002_author_nick (or _email), so the regenerated directory reads 0003_author_bio. |
| M | 247–249 | I-7 | The retirement table shows the declaration in assignment style only (bio: str = Field(index=True)). |
Acceptable inside a table cell, but add "(or Annotated[...], as above)". Alternatively, write the cell in prose: "bio required, indexed" → "bio nullable, retired=True". |
| L | 134 | 1, 20 | The header table carries version history: "A ferro older than this header refuses the file…". | Move it to a version note under the table, or to the changelog. |
| L | 489 | 22 | "A squash is a one-way step for older ferro releases": "step" is a domain term (Step), so this overloads it. | "Older ferro releases cannot read a squash; read …". |
| L | 159, 288, 375, 394, 398, 586 | 5 | Several headings name no feature: "Changes existing rows need a value for", "Going back", "Editing a migration", "Two branches, one number", "Squash or --replace". |
"Backfills (changes existing rows need a value for)", "Reverting with down", "Edited migrations", "Duplicate migration numbers and --replace", "squash vs --replace". |
| L | 88, 216, 257, 286, 543 | 22 | Example scripts are linked by GitHub main URL. On a released docs version these point at a moving branch. |
Link to the tagged version, or embed the relevant region with --8<--. |
| L | (examples) migrations_retire.py:16 vs _annotated.py:19 |
I-7, 12 | The two tabs are not equivalent. Assignment has bio: str | None = Field(index=True, retired=True) with no default; Annotated has = None. |
Give the assignment form default=None, matching every other nullable field in the examples. |
| L | (examples) migrations_retire_label_annotated.py:26 |
I-7 | status: Annotated[Status, FerroField()] = Status.PAID uses an empty FerroField(), while the assignment tab has none. |
Drop the empty FerroField(). The line is then identical in both tabs, as I-7 allows. |
docs/pages/howto/adopting-migrations.md
- Kind (rule 1): how-to (correct).
- Running example (rule 3):
Author+0001/0002_author_slug. The first migration is named
0001_initialin step 2 and0001_create_authoreverywhere after. - Verdict: Tight, correct, and its terminal outputs are pinned by
tests/test_docs_migrations.py. The
CLI flags, exit code 4,AlreadyTrackedError/AppliedAboveBaselineError(bothMigrationRefused
subclasses) and the CLI anchor all check out. The fixes are small: the running example's naming and two
lead-ins. Grade: B.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 12, 20, 63, 104, 159 | 3 | Step 2 writes 0001_initial, but every later output says 0001_create_author. A reader following the steps sees a name they never created. |
Use one name throughout. Either generate initial in the test fixtures for the later outputs, or change step 2 to ferro migrate new create_author. |
| M | 99–106 | 8 | The prose runs ferro migrate baseline 0001 --url "$STAGING_URL", but the pinned output's database line says sqlite:///./app.db, so the output does not match the command shown (test_docs_migrations.py:898 runs it without --url). |
Run the fixture with --url against a second file, so the header shows a different database from production's. |
| M | 58, 146–150 | fact/clarity | "Any difference and it records nothing, prints each one and exits 4" describes the CLI only. In code, baseline() does not raise on drift: it returns a BaselineReport whose drift lists the differences (.raise_for_problems() raises). Line 150 implies this without saying it, and the start-up recipe silently ignores the report. |
In "An app that migrates at start-up", add: "On drift, baseline() records nothing and returns a report listing it, so up() refuses next. Call report.raise_for_problems() if you want start-up to fail at the baseline instead." |
| M | 33–35 | 7 | Step 3 shows the output but never the command as a block (ferro migrate baseline appears only in prose). |
Add a bash block with ferro migrate baseline, then "prints:" and the output. |
| L | 116–118 | 7 | Two stacked lead-ins: "status shows what each database was baselined at:" then "A production database baselined at the head shows …:". |
Merge them: "ferro migrate status on a production database baselined at the head shows both migrations as applied (baseline):". |
| L | 93 | 15 | One 80-word paragraph covers three exemptions (undeclared tables, two Ferro defaults, unowned objects). | Use a three-item list. |
| L | 68 | 22 | Bare "(ADR-0031)". | Link it, or drop it. |
| L | 1 | 5, 24 | Title is in title case. | "Adopting migrations on an existing database". |
Migration examples: migrations_quickstart, migrations_backfill, migrations_rename, migrations_retire, migrations_retire_label, migrations_squash, migrations_testing (each + _annotated)
- Embedded by:
guide/schema/migrations.md,overview.md,data-steps.md,testing.md - Verdict: A− as a group (individually A, except
retire_labelandtestingat B). This is the best-tested part of the examples: the I-7 twins are proven equivalent by byte-identical generated migrations, the# new/# before:markers let one region describe before and after, and the CLI output the pages quote is pinned bytest_docs_migrations.py's output blocks.
| Sev | File:line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | all 7 _annotated files |
I-7 consistency | The migration twins use id: Annotated[int | None, FerroField(primary_key=True)] = None (FerroField, default outside), while the 11 other twins use Annotated[int | None, Field(default=None, primary_key=True)] (Field, default inside). Readers see two different "Annotated styles" on neighbouring pages. |
Pick one idiom for every twin. The migrations form, with the default outside Annotated, is the idiomatic Pydantic one. |
| L | migrations_retire_label_annotated.py:25 |
I-7 / 12 | status: Annotated[Status, FerroField()] = Status.PAID: an empty FerroField() that the assignment tab (status: Status = Status.PAID) doesn't have. It suggests the marker is required. |
status: Status = Status.PAID in both tabs. |
| L | migrations_testing.py:26–44 |
9 | The tests section depends on a database_url fixture the page defines inline (guide/schema/testing.md:39, compile-only). The example drives the tests by hand instead of through pytest, so the fixture as printed is untested. |
Run the section under pytest with the page's fixture, or snippet the fixture from the example too. |
| L | migrations_testing.py:28 |
22 | h = harness(): a one-letter name in reader-facing code. |
migrations = harness(). |
| L | migrations_retire.py:32, 44–45 |
10 | Lowercase names 'ada', 'grace', 'linus'; migrations_backfill uses 'Ada Lovelace'. |
'Ada Lovelace' etc. for consistency. |
| L | all | 8 | The page output blocks are tested (good). The examples themselves check substrings only. | — |
docs/examples/migrations_adopting.py
- Embedded by:
howto/adopting-migrations.md(recipe) - Verdict: B. The recipe is tested hard (two concurrent processes, a brand-new file, a re-start). No twin is needed because the
modelssection is not embedded.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| L | 18–27 | — | The [start:models] marker is unused. |
Delete it, or embed the models so the recipe has its context. Embedding would then need an _annotated twin under I-7. |
| L | 38–43 | 7 | The recipe's comment carries the explanation the page's result sentence should carry. | Keep the code comment to one line and move the rest to the page. |
docs/pages/guide/migrations.md
- Kind (rule 1): redirect stub (not an orphan, not dead).
- Running example (rule 3): n/a
- Verdict: This is a deliberate redirect stub. docs(schema): the Schema Management group around the two doors (#539) #570 (shipped in v0.22.0, 2026-10-08) left it behind when the old single "Schema Migrations" page became the Schema Management group. It holds a moved-sections table plus an inline
<script>that maps old anchors to new pages withlocation.replace. I checked every target anchor against the headings inschema/overview.md,schema/auto-migrate.mdandschema/alembic.md, and all of them resolve. Nothing indocs/pageslinks to it. It exists for external and bookmarked links, which is the right reason to keep it. Keep it for now. Fix the items below, and delete it (or turn it into a server-side redirect) once the old URL has aged out. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 17–40 | mechanics | The redirect is JS-only: no <meta http-equiv="refresh"> and no <noscript>. Search will index the stub as a page titled "Schema Migrations has moved". |
Add front matter search: { exclude: true } (Material/Zensical) and a <meta http-equiv="refresh" content="0; url=../schema/overview/"> fallback, or use a redirect plugin if Zensical supports one. Add not_in_nav/validation config so the build does not warn. |
| M | 5–15 | 1 | Duplicates schema/overview.md L81–89, "Where the old page went", which is a second copy of the same table on a live page. Rule 2: history should not take up the overview's screen. |
Delete the section from overview.md. The stub is the one place readers of old links land. |
| L | 5–15 vs 23 | consistency | The JS map redirects #creating-tables-with-auto_migratetrue, but the visible table has no row for it. |
Add the row. |
| L | 1 | 24 | Title "Schema Migrations has moved" is fine for a stub. | — |
Activity
0x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: sdd worker (reviewed by reviewer-713)
Branch:
sdd/713-schema-pagesPR: #734
Verify: passed 84e8ac0 (trial merge into
feat/docs-rewriteat 853aaac: passed)Review verdicts: Spec PASS, Quality PASS. No Critical or Important findings.
Three Minor findings are in the PR review: the SQLite label-removal wording in data-steps.md:333, the anchor of the "Upgrading from 0.24 to 0.25" link at migrations.md:501, and the fact that nothing enforces
{test=compile reason=...}.Cannot verify: whether the shared
migrations_quickstart_annotated.pytwin uses the one Annotated idiom depends on #714.docs: schema pages fixed against the generator, quoted step files pin…
0x054 commentedon Oct 19, 2026
Merged in #734 into
feat/docs-rewritedocs(api): migrations, pass report and CLI docstrings as Google-style…
docs: split the migrations guide into the loop, renames and retiremen…