Repository navigation
fix(models): retired labels are refused by value on every write path; the primary-key refusal names its fix (panel F4, F5, F8) - #638
Merged
Conversation
Field(retired=True) on a primary key now says to drop retired=True and to change the key first, pointing at the migrations guide's Changing a primary key section, like its four sibling refusals (ADR-0053, I-6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…el F4) The construction and assignment guards compared only str values, so a plain Enum member holding a retired label passed. Both now compare the written value (member.value or the raw label) through one helper, so any enum whose member values include the label is guarded; hydration still bypasses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
The keyword and recipe literal SETs now ask the model's retired-label guard, by value as construction does, and raise the same retired_enum_label ValidationError before anything is compiled or sent (ADR-0053). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
0x054
commented
Oct 12, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Scoped re-review: F4, F5, F8
Reviewed the fix diff c9e3f61..8c4cd01.
- F4: ADDRESSED.
src/ferro/metaclass.py:111written_retired_labelcompares the value that would be written:member.valuefor anyEnum, otherwise the raw value. That catches aStrEnummember, a plainEnummember and a raw string. Both guards use it:_label_guard(:158) and_guarded_setattr(:168). The labels checked are always the field's own enum's (retired_labels[field_name]; inupdate(),_enum_fields[column]), so a different enum's retired label on another field cannot refuse a write. Hydration still runs neither guard (I-2).test_a_hydrated_plain_enum_retired_row_changes_another_field_and_savespins hydrate-then-save for a plainEnum. - F5: ADDRESSED.
src/ferro/metaclass.py:76names the fix ("dropretired=True. To remove the key, change the primary key first: see the Migrations docs § Changing a primary key"). It agrees withdocs/pages/guide/schema/migrations.md:226, which links to §399. A test pins the text. - F8: ADDRESSED.
src/ferro/query/wire.py:558_guarded_literal_assignmentcovers every literal SET: keywordupdate()(:596) and the recipe form (:827). The non-literal recipe values (column copy, arithmetic,now,.merge()) do not write a label literal.save()is untouched, so writing back an unchanged hydrated label still works. models.pyandir/compiler.pyare untouched. No new findings.
Merge preview with origin/feat/pinch-migration-needs (36670c4): the merge is clean, cargo check --all-targets exits 0, and the merge was aborted afterwards.
Verify block on 8c4cd01:
uv run maturin develop: exit 0cargo check --all-targets: exit 0pytest tests/test_ir_vectors_contract.py: exit 0 (14 passed)pytest --db-backends=sqlite,postgreson the nine listed files: exit 0 (438 passed)uv run prek run --all-files: exit 0
1 of 3 tasks
0x054
added a commit
that referenced
this pull request
Oct 12, 2026
… the primary-key refusal names its fix (panel F4, F5, F8) (#638) Panel findings F4, F5 and F8 on PR #636. The retired-label guard now compares the value that would be written, so a plain `Enum` member carrying a retired label is refused like a `StrEnum` one, at construction, on attribute assignment under either `validate_assignment` setting, and in keyword and recipe `update()`; hydration still bypasses it and an unchanged label is still written back by `save()`. The retired primary-key refusal names its fix, and the migrations guide's sentence matches. Part of #623 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #623 (panel fix round for PR #636; #625 and #627's files)
F4: a retired label on a plain
Enumis now refusedBefore this change, the guard only checked values that were a
str. A field typed as a plainclass Status(Enum): CANCELED = "canceled"holds the member after validation, and that member is not astr. SoOrder(status=Status.CANCELED)andorder.status = Status.CANCELEDwrote the retired label without any error.Now one helper,
written_retired_label, compares the value that would actually be written:member.valuefor any enum member, or the raw string. The construction validator and the class-level__setattr__both use it. The__setattr__is still installed only whenvalidate_assignmentis off. The guard stays per-field, andModelgets no__setattr__of its own. Hydration still bypasses the guard (I-2).Tests in
tests/test_retired_labels.py:test_a_plain_enum_refuses_the_retired_label_on_construction[False|True]test_a_plain_enum_refuses_the_retired_label_on_assignment[False|True]test_a_hydrated_plain_enum_retired_row_changes_another_field_and_saves, which runs on both backends with bothvalidate_assignmentsettingsF5: the retired primary-key refusal names its fix
The refusal now reads:
The migrations guide's list of refusals now gives the fix for this case too, as it does for the others. Test:
tests/test_retired_columns.py::test_retiring_a_primary_key_is_refused_naming_the_fixpins the full text.F8:
update()refuses a retired enum labelBefore this change,
Model.where(...).update(status=Status.CANCELED)wrote the retired label to every matching row.Now the literal SETs for both keyword
update(status=...)and the recipe formupdate(lambda o: {"status": ...})go throughrefuse_retired_label_write(inmetaclass.py) before anything is compiled. It uses the same by-value comparison as F4 and raises the sameretired_enum_labelValidationError. Column copies and arithmetic are not literals, so they are not checked: an enum column cannot be an arithmetic target.Test:
test_update_refuses_the_retired_label_and_writes_nothing. It covers keyword and recipe ×StrEnumand plainEnum× sqlite and postgres, for both the member and the raw string. It also checks that the row is unchanged and that a laterupdate()without the label still works.Verify (on 8c4cd01)
Outside Files owned
None. The changed files are
src/ferro/metaclass.py,src/ferro/query/wire.py,tests/test_retired_labels.py,tests/test_retired_columns.pyand the one retirement sentence indocs/pages/guide/schema/migrations.md.models.pyandir/compiler.pyare unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR