Skip to content

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
0x054 merged 3 commits into
feat/pinch-migration-needsfrom
sdd/fix-625-627-panel
Oct 12, 2026
Merged

0x054 merged 3 commits into
feat/pinch-migration-needsfrom
sdd/fix-625-627-panel

Conversation

@0x054

@0x054 0x054 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Part of #623 (panel fix round for PR #636; #625 and #627's files)

F4: a retired label on a plain Enum is now refused

Before this change, the guard only checked values that were a str. A field typed as a plain class Status(Enum): CANCELED = "canceled" holds the member after validation, and that member is not a str. So Order(status=Status.CANCELED) and order.status = Status.CANCELED wrote the retired label without any error.

Now one helper, written_retired_label, compares the value that would actually be written: member.value for any enum member, or the raw string. The construction validator and the class-level __setattr__ both use it. The __setattr__ is still installed only when validate_assignment is off. The guard stays per-field, and Model gets 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 both validate_assignment settings

F5: the retired primary-key refusal names its fix

The refusal now reads:

Author.id: a primary key cannot be retired; drop `retired=True`. To remove the key, change the primary key first: see the Migrations docs § Changing a primary key

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_fix pins the full text.

F8: update() refuses a retired enum label

Before 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 form update(lambda o: {"status": ...}) go through refuse_retired_label_write (in metaclass.py) before anything is compiled. It uses the same by-value comparison as F4 and raises the same retired_enum_label ValidationError. 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 × StrEnum and plain Enum × sqlite and postgres, for both the member and the raw string. It also checks that the row is unchanged and that a later update() without the label still works.

Verify (on 8c4cd01)

uv run maturin develop                                  -> exit 0
cargo check --all-targets                               -> exit 0
uv run pytest -v tests/test_ir_vectors_contract.py      -> exit 0, 14 passed
uv run pytest --db-backends=sqlite,postgres tests/test_retired_labels.py tests/test_retired_columns.py tests/test_schema_enum_annotations.py tests/test_enum_cold_hydration.py tests/test_generate_enum_removal.py tests/test_generate_retired.py tests/test_hydration.py tests/test_docs_examples.py tests/test_docs_migrations.py
                                                        -> exit 0, 438 passed
uv run prek run --all-files                             -> exit 0

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.py and the one retirement sentence in docs/pages/guide/schema/migrations.md. models.py and ir/compiler.py are unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

0x054 and others added 3 commits October 11, 2026 22:35
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 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.

Scoped re-review: F4, F5, F8

Reviewed the fix diff c9e3f61..8c4cd01.

  • F4: ADDRESSED. src/ferro/metaclass.py:111 written_retired_label compares the value that would be written: member.value for any Enum, otherwise the raw value. That catches a StrEnum member, a plain Enum member 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]; in update(), _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_saves pins hydrate-then-save for a plain Enum.
  • F5: ADDRESSED. src/ferro/metaclass.py:76 names the fix ("drop retired=True. To remove the key, change the primary key first: see the Migrations docs § Changing a primary key"). It agrees with docs/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_assignment covers every literal SET: keyword update() (: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.py and ir/compiler.py are 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 0
  • cargo check --all-targets: exit 0
  • pytest tests/test_ir_vectors_contract.py: exit 0 (14 passed)
  • pytest --db-backends=sqlite,postgres on the nine listed files: exit 0 (438 passed)
  • uv run prek run --all-files: exit 0

@0x054
0x054 merged commit 17ce710 into feat/pinch-migration-needs Oct 12, 2026
6 checks passed
@0x054
0x054 deleted the sdd/fix-625-627-panel branch October 12, 2026 02:51
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
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