Skip to content

feat(schema): retired enum labels (#627) - #633

Merged
0x054 merged 2 commits into
feat/pinch-migration-needsfrom
sdd/627-retired-labels
Oct 11, 2026
Merged

0x054 merged 2 commits into
feat/pinch-migration-needsfrom
sdd/627-retired-labels

Conversation

@0x054

@0x054 0x054 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #627

What

A StrEnum can declare __ferro_retired_labels__ = frozenset({"canceled"}). The member stays, so rows holding the label still load, and the Postgres type and any db_check keep listing it. Order(status="canceled") and order.status = Status.CANCELED are refused with a validation error ("Status.CANCELED ('canceled') is retired in this release: the previous release may still write it, this one does not"). A loaded row holding the label saves back after another field changes. ferro migrate new says "no schema change" and check is clean. Deleting the member and the declaration together is the existing label removal, unchanged.

Verify

Ran the ticket's Verify block on the final commit: passed (419 pytest on sqlite+postgres, cargo test -p ferro-schema-ir, IR vector contract, prek).
TDD: RED pytest tests/test_retired_labels.py --db-backends=sqlite failed (7 failed: construction, assignment, IR field, hydrate/save); GREEN same command passed, 23/23 on sqlite+postgres.
Verify block corrected: no

Notes for the reviewer

Outside Files owned: SchemaColumn has no Default, so the new enum_retired_labels field needed one line in every struct literal: src/{codec_plan,state,schema,live_ir}.rs, crates/ferro-migrate/src/{generate/mod,plan,tests,revision_tests,run_plan/step_class}.rs, crates/ferro-ddl-lowering/src/lib.rs. step_class.rs test literal was also missing retired (compile error under --all-targets since #625); fixed. #628 touches live_ir.rs: expect a one-line textual conflict at most. src/ferro/models.py gains a __setattr__ (dict lookup, no-op for models without retired labels) and an after-validator. tests/test_docs_migrations.py lists the new example.

  • Validation lives on construction and attribute assignment only; hydration is untouched (I-2).
  • No planner, renderer or DDL change (I-1).

Fix report (review round 1), head 3bb112f

  • What changed: the model-level after-validator and Model.__setattr__ are gone; models.py is untouched. The metaclass now attaches an AfterValidator to exactly the enum fields whose enum retires a label (then model_rebuild), and installs a __setattr__ guard only on those classes, only when validate_assignment is off. Under validate_assignment=True Pydantic runs the field validator for the assigned field alone, so changing note on a row holding canceled works. Models without retired labels gain nothing.
  • Covering tests (tests/test_retired_labels.py): test_a_hydrated_retired_row_changes_another_field_and_saves[sqlite/postgres x validate_assignment False/True], test_assigning_the_label_fails_with_the_ticket_text_under_both_configs, test_a_model_without_retired_labels_gains_no_setattr_or_validator, plus the earlier construction, assignment, bulk_create and migration tests.
  • Ran with UV_FROZEN=1 and FERRO_POSTGRES_URL exported: maturin develop 0, cargo test -p ferro-schema-ir 0, tests/test_ir_vectors_contract.py 0, the Verify pytest list on sqlite+postgres: 426 passed, prek run --all-files 0. Full suite (sqlite): 2826 passed, 324 skipped, 2 xfailed. sdd-verify note recorded on HEAD and pushed.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

…627)

__ferro_retired_labels__ on a StrEnum stops this release writing a label
while the member, the Postgres type and any db_check keep it. Construction
and attribute assignment refuse it; hydrated rows save back unchanged.
The IR carries enum_retired_labels on every column of the enum; no DDL.

Co-Authored-By: Claude Fable 5.1 <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.

Spec: PASS

  • Minor · docs/examples/migrations_retire_label.py:40 at 22ee776 · Misunderstood · the PROBE's "a row release A wrote hydrates" uses Order.model_construct(...), not a query, although the script has just inserted the row; the ticket's hydrate-and-save path is pinned for real by test_a_row_holding_the_retired_label_hydrates_and_saves_after_another_change (sqlite + postgres), so this is a docs-fidelity nit only.
  • Checked: every acceptance criterion and Test-scenario line has a test in tests/test_retired_labels.py (shape/unknown-label TypeError, construction + assignment in both styles, real hydrate → change note → save() on both dialects, bulk_create beside a hydrated retired row, new = "no schema change" + check exit 0 + live enum_range / ck_rmlorder_status still list canceled, member+declaration deletion = the existing SWAP statements).
  • Checked I-1: no planner, renderer or DDL change; tests/test_generate_enum_removal.py and the IR vectors unchanged against origin/feat/pinch-migration-needs; enum_retired_labels is serde(default, skip_serializing_if = "Option::is_none") and the compiler omits the key when undeclared.
  • Checked I-7: both tabs backed by migrations_retire_label{,_annotated}.py, run by tests/test_docs_migrations.py (byte-identical migrations across styles) and tests/test_docs_examples.py.
  • Declared files outside Files owned accepted: the SchemaColumn literals (no Default on the struct), step_class.rs retired: false (confirmed: at merge-base c5b9e72, cargo test -p ferro-migrate --no-run fails with E0063 missing field 'retired' at crates/ferro-migrate/src/run_plan/step_class.rs:409), the docs-test list entry, and models.py (the validator seam the ticket asks for).
    Cannot verify: closingIssuesReferences is empty because the base is not the default branch; the body carries Closes #627, which takes effect when the integration branch reaches main.

Quality: FAIL

  • Important · src/ferro/models.py:397 at 22ee776 · the construction guard is a model_validator(mode="after") that scans every guarded field of the whole instance. Pydantic re-runs model after-validators on every assignment when validate_assignment=True (a config docs/pages/guide/models-and-fields.md:541 says works), so on such a model a row that holds canceled can no longer have another field changed: O.model_construct(id=1, status=St.CANCELED); o.note = "b" raises ValidationError: St.CANCELED ('canceled') is retired in this release… (reproduced on the PR head). That breaks the ticket's "changing another field and saving succeeds" for every validate_assignment model. Fix: guard the enum field's own value (a per-field after validator on the retired-label fields, which Pydantic runs at construction and, under validate_assignment, only when that field is assigned), plus a test with validate_assignment=True.
  • Important · src/ferro/models.py:376 at 22ee776 · __setattr__ is defined on the Model base, so every attribute assignment on every model pays a Python frame, a class-dict lookup and a super() call for a feature only enums that declare __ferro_retired_labels__ use. Measured on the PR head (pydantic 2.12.5): Model.__setattr__ 207 ns vs BaseModel.__setattr__ 96 ns per assignment on a model with no retired labels, roughly 2x, and it also puts a Python frame in front of pydantic's memoized __pydantic_setattr_handlers__ fast path. The metaclass already knows the guarded fields (src/ferro/metaclass.py:673), so install the assignment guard only on classes whose __ferro_retired_enum_labels__ is non-empty (and skip it when validate_assignment=True, where the per-field validator above already covers assignment). Cost elsewhere is then zero. The same applies to the base-level after-validator, which now runs on every construction of every model.
  • Minor · src/ferro/models.py:407 at 22ee776 · construction raises a value_error with an empty loc and the prefix "Value error, ", while assignment raises retired_enum_label at loc=(name,). The two refusals of one rule have different error types and locations. A per-field validator raising the same PydanticCustomError would make them one.

Verify: passed on 22ee776 (uv run maturin develop 0; cargo test -p ferro-schema-ir 0, 47 passed; pytest tests/test_ir_vectors_contract.py 0, 14 passed; the sqlite+postgres pytest set 0, 419 passed; uv run prek run --all-files 0, all hooks Passed). No sdd-verify note on the head commit.

)

An AfterValidator on exactly the enum fields that retire a label replaces
the model-level validator, so validate_assignment no longer blocks changing
another field of a row holding the label. The assignment guard is installed
only on classes that retire a label and only without validate_assignment;
Model.__setattr__ is untouched.

Co-Authored-By: Claude Fable 5.1 <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 22ee776..3bb112f

  1. ADDRESSED · Important · src/ferro/models.py:397 (validate_assignment) · src/ferro/models.py is byte-identical to origin/feat/pinch-migration-needs at 3bb112f. src/ferro/metaclass.py:735-739 now appends an AfterValidator only to the guarded enum fields' FieldInfo.metadata and rebuilds the class. Under validate_assignment=True, assigning note on a model_constructed status=CANCELED instance now succeeds; assigning the retired label is still refused (loc=('status',)). test_a_hydrated_retired_row_changes_another_field_and_saves passes for [sqlite|postgres] × [False|True] (4/4), run here; the True case is the one that failed at 22ee776.
  2. ADDRESSED · Important · src/ferro/models.py:376 (__setattr__ on the base) · the base is untouched (Model.__setattr__ is BaseModel.__setattr__). src/ferro/metaclass.py:741 assigns cls.__setattr__ only when the class has retired-label fields and validate_assignment is off. Checked: a sibling model defined after a retiring one has no __setattr__ of its own, the retiring class has its own, and a validate_assignment class has none. The guard survives resolve_relationships()'s forced rebuild on a model with a FK; construction is still refused afterwards. test_a_model_without_retired_labels_gains_no_setattr_or_validator passes.
  3. Minor (previous) · partly addressed · construction now reports loc=('status',) like assignment does, but the error type still differs: value_error from the AfterValidator (src/ferro/metaclass.py:108) and retired_enum_label from the __setattr__ (src/ferro/metaclass.py:127). Does not block.

New Critical/Important in the fix diff: none.
Checked: test_assigning_the_label_fails_with_the_ticket_text_under_both_configs [False|True] passes. Hydration still bypasses both checks (I-2): the hydrate→change→save tests above pass on both dialects. The sdd-verify note exists on 3bb112f.

Verify: passed on 3bb112f (uv run maturin develop 0; cargo test -p ferro-schema-ir 0, 47 passed; pytest tests/test_ir_vectors_contract.py 0, 14 passed; the sqlite+postgres pytest set 0, 426 passed; uv run prek run --all-files 0, all hooks Passed).

@0x054
0x054 merged commit 682c30b into feat/pinch-migration-needs Oct 11, 2026
6 checks passed
@0x054
0x054 deleted the sdd/627-retired-labels branch October 11, 2026 17:26
0x054 added a commit that referenced this pull request Oct 11, 2026
The planner's test column gains enum_retired_labels; the rustfmt-only hunks
in files this change does not touch are dropped; the serial-sequence test
is split into its up-built and pass-built halves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
0x054 added a commit that referenced this pull request Oct 12, 2026
…627) (#633)

`__ferro_retired_labels__ = frozenset({"canceled"})` on a `StrEnum` keeps the member, so rows holding the label still hydrate and save, while assigning the label at construction or by attribute set is refused with an error naming it as retired in this release. The label stays in the Postgres enum type and in any `db_check` check; retiring renders no DDL and writes no migration. The guard is a per-field validator attached only to enum fields whose enum retires a label; models without retired labels gain nothing. Also carries the `retired` field a `SchemaColumn` test literal in the step-class golden table was missing after #625 and #626 merged independently.

Closes #627

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