Repository navigation
refactor(models): one retired-label refusal type on every write path; allow_ahead parsed once (architecture pass on #636) - #640
Conversation
The field validator raised ValueError, so construction and assignment under validate_assignment surfaced as value_error while attribute set and update() surfaced as retired_enum_label. refuse_retired_label is now the one refusal (a PydanticCustomError the AfterValidator raises as is); refuse_retired_label_write carries it out of the paths that run no validation. Every path yields retired_enum_label with today's text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…nnecting check_allow_ahead (Python) and allow_ahead_arg (Rust) held the same three-value vocabulary and TypeError text by hand. _core._allow_ahead exposes allow_ahead_arg's parse; up() and require_applied() call it before resolving a target, so the refusal still arrives before any connection opens. check_allow_ahead is gone. The interface test now pins the exact text and that no connection is attempted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
0x054
left a comment
There was a problem hiding this comment.
Spec: PASS
No findings against the two cards (PR body is the spec; no sub-issue, Closes rule waived by the orchestrator).
- Card 2:
test_every_write_path_refuses_with_the_retired_enum_label_typeran 20 passed (5 paths × StrEnum/plain Enum × sqlite/postgres) and assertstype, exactmsg,loc.refuse_retired_label(metaclass.py:116 at dc93200) is the only place that builds the refusal: theAfterValidatortakes it viapartial, and__setattr__, keywordupdate()and recipeupdate()(wire.py:566) take it viarefuse_retired_label_write. Old_label_guardraisedValueError, which is why construction and validated assignment reportedvalue_error. Value error,prefix: no doc or test pins it. docs/pages/guide/schema/migrations.md:250 and docs/examples/migrations_retire_label*.py check only the message body (settings.py:721strips the prefix for an unrelated path).- Hydration bypass: the existing hydrated-row save tests (test_retired_labels.py:149/197/338/410) pass.
- Card 6:
grep -rn "expand-only" src/ferroturns up only types, docstrings, the CLI annotation (cli/migrate.py:326, unchanged) and_core.pyi's stub for_allow_ahead.allow_ahead_arg(migrations_ffi.rs:299) is the only parser. The new FFI fn returnsPyResultwith nounwrap(). I swapped_core._allow_aheadfor an identity function and ran the test: all 3 cases fail withOperationalError: unable to open database file, so the test does prove the TypeError comes before connecting.
Cannot verify: none.
Quality: PASS
No Critical or Important findings. I-2, I-3, I-6 hold (the refusal names False, True or "expand-only"). Tests: one strengthened (test_allow_ahead_takes_only_its_three_values), one added, none removed, and none assert internals. No churn in untouched files. Overlap with refactor/deepen-step-class-and-moves: src/migrations_ffi.rs register() gains one line (:27). That is not _generate_migration, but adjacent registration edits could conflict textually.
Merge preview: merging origin/feat/pinch-migration-needs → "Already up to date"; cargo check --all-targets exit 0.
Verify: passed on dc93200 (maturin develop 0; cargo check 0; cargo test 0, 354 passed; ir_vectors 0, 14 passed; pytest matrix 0, 545 passed / 6 skipped; prek 0). sdd-verify note: verified 2026-10-12T03:30:05Z.
… allow_ahead parsed once (architecture pass on #636) (#640) Architecture pass on PR #636, cards 2 and 6. One function raises the retired-label refusal as a `retired_enum_label` error on every write path, construction and validated assignment included, which used to surface as `value_error`; hydration still bypasses it. The `allow_ahead` vocabulary is parsed once, in the core, exposed over the FFI and called before any connection opens; the Python duplicate is gone, and the interface test now proves the refusal arrives before connecting. Part of #623 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
Part of #623 (deepening pass on PR #636)
Two decisions that each lived in several places now live in one.
Card 2: one retired-label refusal on every write path
Take
status: StatuswhereStatus.__ferro_retired_labels__ = frozenset({"canceled"}).Before.
Order(status="canceled")raised aValidationErrorwhose error type wasvalue_error(the message prefixedValue error, ...).order.status = "canceled"withvalidate_assignment=Truedid the same. Withvalidate_assignment=False, and inupdate(status=...)orupdate(lambda o: {"status": ...}), the type wasretired_enum_label. Four places built the refusal:_label_guard,_guarded_setattr,retired_label_errorandrefuse_retired_label_write. The docstring said every path gaveretired_enum_label, and the tests only checked the message text, so nothing caught the gap.After.
refuse_retired_label(enum_cls, labels, value)is the only refusal. It raisesPydanticCustomError("retired_enum_label", <the ticket's text>). The field'sAfterValidatoris that function with its first two arguments bound, and Pydantic reports aPydanticCustomErrorfrom a validator under its own type.refuse_retired_label_write(model_cls, column, value)covers the paths that skip validation (__setattr__withoutvalidate_assignment, andupdate()in both forms): it calls the refusal and raises theValidationErrorthat validation would have raised. Every path now giveserrors()[0]["type"] == "retired_enum_label",loc == ("status",), and the same text as before. Hydration still skips the guard (I-2).Visible change: construction, and assignment under
validate_assignment=True, now reportretired_enum_labelwhere they used to reportvalue_error, anderrors()[0]["msg"]loses theValue error,prefix. Every other path is unchanged.Interface test:
test_every_write_path_refuses_with_the_retired_enum_label_typecovers 5 write paths (construction, assignment, validated assignment, keywordupdate(), recipeupdate()) ×StrEnumand plainEnum× SQLite and Postgres, and checks type, exact message and loc. RED before the change: 8 failures, which were construction and validated assignment for both enum kinds on both dialects. No other test changed.Card 6:
allow_aheadspelled onceBefore. Python's
check_allow_aheadand Rust'sallow_ahead_argeach kept their own copy ofFalse | True | "expand-only"and of theTypeErrortext. Python ran first, so the Rust branch could not be reached from the public API.After.
_core._allow_ahead(value)callsallow_ahead_argand returns the value in its Python spelling, or raises itsTypeError.up()andrequire_applied()call it before they resolve a target, so the refusal still comes before any connection opens.check_allow_aheadis deleted. The CLI callsup()and needed no change. TheLiteralannotations stay as types only. TheTypeErrortext is unchanged byte for byte:allow_ahead must be False, True or "expand-only"; got 'sometimes'.Interface test:
test_allow_ahead_takes_only_its_three_valuesgoes through the public API only, parametrised over'sometimes',1andNone. It asserts the exact text and points both calls at a SQLite URL that cannot be opened, so it now also proves the error comes before any connection. Withcheck_allow_aheaddeleted and nothing put in its place, the old version of this test still passed, because the Rust check fires after connecting but before any write. The strengthened test fails there withOperationalError: unable to open database file. Nothing importedcheck_allow_ahead, so no test was removed.Verify
Outside Files owned
None. In
src/migrations_ffi.rsthe only changes are the new_allow_aheadbesideallow_ahead_argand its registration.🤖 Generated with Claude Code
https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR