Skip to content

Split the migrations guide: tutorial loop, how-tos, a squash page, and reference tables in Reference #714

Closed

Description

@0x054

Parent

#698

What to build

The 664-line migrations guide wraps five how-tos and several reference tables around the tutorial loop, and three overlapping "which door handles which change" tables already disagree with each other.

  • The guide keeps the tutorial loop (new → review → up).
  • Renames, retirements, label retirement and squash become how-to pages; squash gets its own page, in the CONTEXT.md vocabulary (Range, Inheritance).
  • The auto-migrate matrix, the PassReport kinds, the migration header table, the exit codes, the door comparison and the expand/contract table each get one home in a new Reference page and are linked from the guides; the copies on other schema pages are removed.
  • The doc-output pins in tests/test_docs_migrations.py follow the blocks to their new pages. New pages are not added to the nav; the nav ticket (Navigation: six tabs, sentence-case labels that match page titles, moved pages redirected #723) wires them.

Pages: guide/schema/migrations.md, guide/schema/squash.md, guide/schema/renames-and-retirements.md, reference/migrations.md

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_checks reports 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).
  • Each of the six reference tables appears exactly once in the site source.
  • Every old anchor of the migrations guide that moved resolves (the strict build checks it) or is listed for the nav ticket (Navigation: six tabs, sentence-case labels that match page titles, moved pages redirected #723)'s redirects in the PR body.

Files owned

  • Create: docs/pages/guide/schema/squash.md, docs/pages/guide/schema/renames-and-retirements.md, docs/pages/reference/migrations.md
  • Modify: docs/pages/guide/schema/migrations.md, docs/pages/guide/schema/overview.md, docs/pages/guide/schema/auto-migrate.md, docs/pages/guide/schema/deploying.md, docs/pages/guide/schema/data-steps.md, docs/pages/reference/cli.md, docs/examples/migrations_quickstart.py, docs/examples/migrations_rename.py, docs/examples/migrations_retire.py, docs/examples/migrations_retire_label.py, docs/examples/migrations_squash.py, docs/examples/migrations_quickstart_annotated.py, docs/examples/migrations_rename_annotated.py, docs/examples/migrations_retire_annotated.py, docs/examples/migrations_retire_label_annotated.py, docs/examples/migrations_squash_annotated.py
  • Test: tests/test_docs_migrations.py

Interfaces

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 raise an owned example's error check expects makes that example's test fail.
  • Edge case: python -m tests._docs_checks over the owned files exits 0; renaming one lambda parameter to t makes it exit 1 naming the file and line.

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/reference/migrations.md

Blocked by

Audit findings

Rows from the docs audit this ticket covers (file, line, rule, fix)

docs/pages/guide/schema/migrations.md

  • Kind (rule 1): mixed. It holds a tutorial-ish loop (init → new → up), reference (header table, check/status/drift exit codes, --replace rules, 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 + nickname through "Reading a migration". It then drifts to Writer/handle/Status (renames), Author.bio (retire), Order/Status (retire label), 0002_add_x/add_y with bio/nick (branches), Team/tmp_code (squash), Author.handle/bio (squash rename), and Writer/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.

Does the section split cleanly by kind? (rule 1)

The seven pages split by topic, and only roughly by kind. The overlaps:

  1. Three coverage matrices for one question. "Which change does which door handle?" is answered by overview.md "What each door covers" (14 rows), auto-migrate.md lines 29–57 (29 rows, per backend), and alembic.md "What autogenerate refuses". They already disagree once (the Alembic/SQLite required-column row). Recommendation: one reference page holds the full per-backend matrix, and the guides keep a short summary and link to it.
  2. check / status / drift / exit codes appear in migrations.md "Checking", deploying.md "In CI" and "Where a database stands", and reference/cli.md. Let the CLI reference own the codes.
  3. The SQLite table rebuild block appears twice, verbatim and stale in both places: migrations.md:327 and data-steps.md:69.
  4. Rolling deploys / two releases are spread across migrations.md (rename online, retire column, retire label, PK recipe's last paragraph), data-steps.md (late row) and deploying.md (two-release contract, step classes). Recommendation: retiring a column or label is the how-to half of the two-release contract, so "Renames and retirement" plus the two-release contract could be one "Changing schema under a rolling deploy" page.
  5. migrations.md is five how-tos plus reference wrapped around a tutorial loop. Split it as described in its table above. squash especially warrants its own page.
  6. Release-specific notes (deploying.md "Upgrading to the release with squash", the version history in the header table, "Added in 0.11.0") belong in upgrade notes or the changelog, not in guides.

data-steps.md and testing.md are cleanly scoped. overview.md is a proper landing page once the "old page" map goes.

Cross-cutting patterns

  1. Generated-file blocks are unpinned and three have rotted (3 blocks, 2 files). The <!-- output: --> CLI blocks are tested and correct. The -- NNNN_…/….sql file blocks are not tested, and three are stale against the generator: 0001 CREATE TABLE, and the SQLite rebuild twice. Fix: extend tests/test_docs_migrations.py to regenerate and compare every quoted step file, the way it already does for output.
  2. Title-case headings (12): every "See Also" (7) plus the page titles "Schema Management", "Data Steps and Backfills", "Deploying Migrations", "Testing Migrations", and the nav labels. Rule 5 asks for sentence case.
  3. Headings that are clauses, slogans or metaphors, not names (≈18): "The ladder", "Choosing", "Going back", "Two processes at once", "SQLite: warn and skip", "One decider", "A squash is pre-release", "Every database that applied the originals inherits the squash", "When a late row arrives", "Squash or --replace", and others.
  4. Code blocks missing a lead-in sentence (≈14): auto-migrate 9, 226; migrations 7, 92 plus 6 commands written as bare inline-code paragraphs; data-steps 145, 156, 202; alembic 9, 17, 84; testing 62. There are also 3 places in data-steps with doubled lead-ins.
  5. Internal references in user prose (12): 9 ADR numbers across 6 pages, the AGENTS.md "pins" paragraph, the SDD word "epic", and "One decider".
  6. Running example drifts within a page (4 pages): migrations.md uses 7 different models, auto-migrate.md 4, and alembic.md has none (card). data-steps, testing and deploying hold Author.slug well.
  7. Reference material embedded in guides (5 large blocks): the auto-migrate matrix, the PassReport kinds, the migration header table, the exit codes (twice), and the expand/contract table. Each should live once in reference/ or api/ and be linked from the guides.
  8. Time-bound or version-history text in guides (6): "Written 2026-10-18…", "first release after 0.24", "Added in 0.11.0" ×2, "Added in 0.18.0", "0.18.0+", the 0.22 record-class paragraph, and the header-table note about older ferro.

Activity

  1. added
    documentationImprovements or additions to documentation
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:LCross-cutting or multi-worker; local waves only
    on Oct 19, 2026
  2. added theissue type on Oct 19, 2026
  3. self-assigned this
    on Oct 19, 2026
  4. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Status: verify-passed
    Worker: worker-714
    Branch: sdd/714-migrations-split
    PR: #744
    Verify: passed cf83b7a; trial merge into feat/docs-rewrite at 7bf0e39 passed

    Scoped re-review by reviewer-714: Spec PASS, Quality PASS.

    • Both findings are addressed: the upgrade guide's two dead anchors, and the repeated phrase at migrations.md:55.
    • The strict build passes on the new head and on the trial merge.
    • On the trial merge, test_docs_migrations.py passes in full; the one skip needs Postgres. That fixes the base's RLS failures.
    • One Minor stays open: squash.md:3.
  5. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Merged in #744 into feat/docs-rewrite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves only

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions