Skip to content

docs: 0.26 notes state the secret-column change; RunRefused exported and documented (panel Round 3 F42, F44) - #784

Merged
0x054 merged 3 commits into
feat/docs-rewritefrom
sdd/698-fix-f42-f44
Oct 21, 2026
Merged

0x054 merged 3 commits into
feat/docs-rewritefrom
sdd/698-fix-f42-f44

Conversation

@0x054

@0x054 0x054 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

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.

  • Issue status: no issue closes here; this is part of Docs rewrite: every snippet runs, every fact matches the code, and the API reference renders cleanly #698.

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

🤖 Generated with Claude Code
https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

0x054 and others added 2 commits October 21, 2026 17:42
0.25 wrote a SecretStr/SecretBytes column as the mask `**********`, in
field order, loaded it back as the str mask, and created SecretBytes
columns as varchar. The 0.26 notes said a secret column used to come
last and that rows were unchanged. Drop "or secret" from the order
bullet and add a fifth bullet: the real value is stored and loads as the
secret type, 0.25 rows hold an unrecoverable mask, and a SecretBytes
column needs a migration to BYTEA/BLOB before 0.26 saves into it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
The tracked-database example prints `RunRefused`, a class no page
documented and ferro.migrations did not export. Export it, document it
in the migrations API page and the exceptions tree, and say on the
Overview page what to catch. The printed-output check now fails a block
that names a Ferro exception class no public path exports.

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

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec: FAIL

  • S1 · Critical · docs/pages/guide/schema/overview.md:50 at 640726c · Misunderstood · "A RunRefused from a migration run names its refusal in kind" is false. Several refusals raised during a run carry kind=None. Examples: runner.py:828 (_order_keys_for, "Nothing was applied/reverted"), runner.py:949 (HistoricalModelError → "Nothing more was run."), runner.py:1453 (down) and runner.py:459. The class's own attribute docstring says kind is None for "a refusal that is not the planner's". A reader who matches kind on every run refusal gets None. F44 asks for an accurate sentence telling the reader what to catch. The catch half is right. Fix: "A refusal the run planner makes names itself in kind; this one is not the planner's, and its kind is None." The same overclaim appears at docs/pages/api/exceptions.md:25 (".kind names why").
  • F42: verified. Every claim in the new bullet holds at 640726c and at v0.25.0. 0.25 wrote the mask on save()/bulk_create() (model_dump over SecretBytes) and on update() (to_json in update_bind_payload). bytes alone came last (overlay after the dump). A probe over a hand-made varchar SecretBytes column gave these results:
    • Postgres: save(SecretBytes(b"\xff\x00k")) succeeds, the raw value is '\\xff006b', and get returns SecretBytes(b'\\xff006b'). No error, as the page says.
    • SQLite: the value round-trips (stored as a blob).
    • The pass plans ALTER TABLE "vault" ALTER COLUMN "blob" TYPE bytea USING "blob"::bytea on Postgres. On SQLite it points to ferro migrate new. The IR format changes from password to binary, so a snapshot diff sees it.
    • The count is now "Five changes". The section is pinned by test_the_upgrade_guide_states_the_secret_column_change.
  • F44 otherwise met: __all__ export, API page entry, exceptions tree (MRO matches), public-only recogniser plus the internal-name test. kind is None for the connect refusal (run.rs:69 raises RunRefused(text)). The strict build renders ferro.migrations.RunRefused.kind once.
  • PR body carries both required lines. Every file is in the allowed list.
    Cannot verify: none

Quality: PASS

  • Minor · docs/pages/api/exceptions.md:25 at 640726c · "a migration run refused before it ran anything" now also covers connect(auto_migrate=True), which is not a run, and .kind is often None (see S1). Fix: "a migration run, or auto-migrate on a tracked database, refused before anything ran".

Verify: PR head 640726c passed (strict build clean; pytest 1429 passed on sqlite+postgres with FERRO_POSTGRES_URL set; _docs_checks exit 0; ruff check and format clean)
Trial merge: not run (base unchanged)

RunRefused.kind is None on many refusals, the tracked-database refusal of
connect(auto_migrate=True) among them. The Overview and the exceptions
tree said kind always names the refusal. State that only some refusals
carry a kind and that the others are matched on the message or the
MigrationRefused base; name connect(auto_migrate=True) as a door that
raises RunRefused.

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

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: 640726c → 2775816

  • S1 · ADDRESSED · docs/pages/guide/schema/overview.md:50, src/ferro/migrations/report.py:55-57 and :84-89 at 2775816. No sentence on either page or in the RunRefused docstrings now claims every refusal carries kind.
    • Overview: the block's refusal really has kind=None. src/run.rs:69 refused() calls RunRefused(text), and src/migrate.rs:643 supplies the text.
    • The kind docstring names three refusals with kind=None, and each raise site confirms it:
      • the tracked-database refusal: refused() as above;
      • a lost run lock: src/run.rs:215,217, LockCheckFailed::into_py → refused(...);
      • a data step that does not load: src/ferro/migrations/runner.py:855, RunRefused(f"{refused}. Nothing was {nothing}.") with no kind.
  • Q1 · ADDRESSED · docs/pages/api/exceptions.md:25 at 2775816. The tree now names both doors: "a migration run, or connect(auto_migrate=True) on a tracked database, refused".

New findings in the fix diff: none at Critical or Important.

Out-of-scope observations: none.

Verify: PR head 2775816 passed. Strict build clean. pytest: 1430 passed on sqlite+postgres, with FERRO_POSTGRES_URL set. _docs_checks exit 0. ruff check and format clean.
Trial merge: not run (base unchanged, f20bcca)

@0x054
0x054 merged commit 0b72ba5 into feat/docs-rewrite Oct 21, 2026
7 checks passed
@0x054
0x054 deleted the sdd/698-fix-f42-f44 branch October 21, 2026 21:53
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.

1 participant