Enforce Post-Construction Type Safety on the Core Branch Only, in Three Ratcheted Steps
Context and Problem Statement
Pydantic v2 validates a model at construction. VultronBase.model_config is
ConfigDict(populate_by_name=True) and does not set validate_assignment, so
validation stops the moment an object exists. The same value the constructor
rejects is silently accepted through two other doors:
case = VulnerabilityCase(case_participants=[wire_obj]) # ValidationError
case.case_participants = [wire_obj] # accepted
case.case_participants.append(wire_obj) # accepted
This matters because the core and wire branches (ADR-0017) carry structurally
incompatible shapes for the same concepts. A wire-shaped object in a core-typed
field does not raise when read — it reads as absent, and readers substitute an
initial state, silently resetting a participant's RM ladder. That is the failure
mode of #2232 / #2264, and it is the same class of defect as #2233, which had
seven Demo Integration scenarios red on main at the time of writing.
ADR-0062 already closed the two boundaries where wire data crosses into persistence: normalisation at ingress and again at the persistence boundary. What remains unenforced is the in-memory invariant — nothing stops code already inside the core from planting a wrong-shaped object in a typed field.
The obvious remedy is validate_assignment=True on VultronBase. It was
measured, and it does not work as-is.
Decision Drivers
- The invariant should hold whichever door a value arrives through.
VultronBaseis shared by both branches (ARCH-12-001), and ARCH-12-002 requires it to stay lenient for the wire branch — inbound wire data is legitimately loose.- Whatever we adopt must be measurable and reviewable in increments; a change that touches every domain object cannot land as one diff.
- Progress must be visible and drift-proof across multiple issues and weeks.
- Re-validation cost on BT tick loops and ledger replay was unquantified.
What the measurements showed
Against origin/main (baseline: 2 pre-existing unrelated failures, #2274):
| Variant | FAILED | ERROR | Failure types |
|---|---|---|---|
validate_assignment on VultronBase |
747 | 423 | 879 × RecursionError, nothing else |
validate_assignment on CoreObject |
398 | 268 | 403 × RecursionError, nothing else |
Not one type-strictness failure was visible in either variant. The cause is
uniform: validate_assignment re-runs every mode="after" model validator on
each assignment, so a validator that writes to self re-enters itself. Twenty
three core validators do this (plus twelve in the wire branch). Guarded ones
terminate at depth 2; unguarded ones — FinderParticipant._set_role,
ReporterParticipant._set_accepted_status, as_EmbargoEvent.set_name and
others — recurse until the stack is gone. The recursion masks the real blast
radius, which therefore cannot be measured until the validators are fixed.
Two further findings shaped the decision:
validate_assignmentdoes not close the append door at all.list.appendis not an attribute assignment, so Pydantic never observes it. A separate mechanism is required regardless.- Cost is not the obstacle. Scalar attribute assignment measured
475 ns → 1464 ns (3.1×, ~1 µs absolute) — immaterial for BT tick loops. The
open question is collection fields, where assigning
case_participantsre-validates all N items.
Considered Options
Four decisions were made, each with alternatives rejected.
A — Overall strategy
- Three ordered steps: fix validators, then flip the core branch, then close the append door (chosen)
- Abandon
validate_assignment; rely on architecture ratchet tests alone - One big-bang PR doing all of it
- Close #2261 as won't-fix, since ADR-0062 covers the concrete defect
B — Where the flag goes
- A core-only mixin applied to every core model (chosen)
CoreObjectonly, accepting the gaps- Per-class opt-in on the highest-risk types
VultronBase— everything, core and wire
C — How validators are made assignment-safe
- Ban self-assignment in
mode="after"; derive inmode="before"(chosen) - Sanction
self.__dict__["field"] = valueas an escape hatch - A re-entry guard flag on the model
- Case-by-case mix of
mode="before"and the escape hatch
D — How the append door is closed
- Spec prohibition + canonical mutators + AST ratchet (chosen)
- Frozen/tuple collection fields
- A validating sequence wrapper type
- Ratchet now, wrapper later if it proves insufficient
Decision Outcome
A — Three ordered steps, because the measured recursion wall makes any single-PR approach unreviewable, and because the true type-strictness blast radius is unknowable until step 1 lands. Each step is independently valuable: step 1 is a correctness-neutral refactor, step 3 shares no files with the others and runs in parallel.
B — A core-only mixin, applied to the ten root classes from which all 103
core models descend. VultronBase is excluded permanently, not deferred: it is
the shared base and as_Base inherits it, so setting the flag there violates
ARCH-12-002 and produced the worst measured result. CoreObject-only was
rejected because 16 core models sit outside it — including all five dimension
objects (RmDimension, VfdDimension, EmDimension, PxaDimension,
PecDimension), which are precisely the types implicated in #2232. Per-class
opt-in was rejected because an opt-in list rots: the default stays unsafe and
every new model is unprotected unless someone remembers.
C — Derive in mode="before", because the derived value is then validated
normally. The __dict__ escape hatch would trade one silent hole for a smaller
one — it writes the field unvalidated and does not update model_fields_set —
and a rule of the form "MUST NOT, except where noted" needs its own allowlist,
which weakens the ratchet. Scope is vultron/core/ only; the wire branch's
twelve equivalent validators are exempt by design, because the wire branch
never gets validate_assignment and therefore cannot recurse.
D — Spec prohibition, canonical mutators, and an AST ratchet, because this
exact pattern is already proven in-repo: PRM-03-001 plus
test_participant_case_roles.py drove direct case_roles mutation in
vultron/ to zero sites. add_participant() and remove_participant()
already exist. Frozen collections would be a breaking change across ~142
test-side mutation sites; a validating sequence wrapper is a novel type with
serialization and copy semantics to maintain, and no evidence yet that the
static scan is insufficient.
Ratcheting across the steps
Work spanning three issues loses coherence, and this repo has direct evidence of
how: the strict=False xfails for #1991 and #1992 have sat green-and-ignored
since they were filed. A non-strict xfail keeps passing after the work is done,
so it never tells anyone to remove it, and it gives no partial-progress signal.
test/architecture/test_validate_assignment_ratchet.py therefore lands with
this decision, before any implementation, carrying three exact-set backlogs:
23 self-assigning validators, 10 mixin targets, 3 collection-mutation modules.
Each is asserted with ==, so it fails if the backlog grows and if an entry
is fixed without being ticked off. Each has a companion xfail(strict=True)
goal test: when the last entry goes, the goal test XPASSes, which fails the
build and forces the marker to be deleted. The #1991 and #1992 markers were
converted to the same shape in the same change.
Consequences
- Good, because the invariant becomes enforced rather than documented, and the #2232 class of silent state reset becomes a loud failure.
- Good, because the plan is encoded in CI from day one; neither drift nor completion can go unnoticed, and partial progress is visible as a shrinking set.
- Good, because ARCH-12-002 is preserved and asserted by a test, so a future "finish the job" PR cannot quietly move the flag onto the shared base.
- Bad, because 23 validators must be rewritten before any type safety is gained — real work with no user-visible benefit.
- Bad, because the type-strictness blast radius remains unknown until step 1 lands, so step 2 cannot be sized in advance.
- Bad, because the append door stays open until step 3, and the static scan cannot see a mutation computed at runtime.
- Neutral: the wire branch keeps its twelve self-assigning validators. If it
ever gains
validate_assignment, this trap returns; the exemption is recorded here deliberately rather than left implicit.
Validation
test/architecture/test_validate_assignment_ratchet.py— three exact-set backlogs, three strict goal tests, plustest_detectors_are_not_vacuous,test_shared_base_is_never_a_targetandtest_wire_branch_does_not_enable_validate_assignment. The heaviest scan runs in 0.55 s.- The ratchet was verified in both directions before merge: injecting a new
mutation site fails the exact-set test, and neutralising all three backlog-3
sites turns the goal test into
[XPASS(strict)]→ FAILED, proving the completion path forces cleanup. - Step 2 carries an acceptance criterion to benchmark a BT tick loop and a ledger replay, quantifying the O(N) collection-field cost and stating the threshold above which the placement would be narrowed.
More Information
Generated spec entries: ARCH-21-001 through ARCH-21-005
(specs/architecture.yaml), CM-27-001 through CM-27-003
(specs/case-management.yaml), PRM-03-003
(specs/participant-role-management.yaml).
Source: issue #2261 (Concern), planned under Epic #2229.
Related: ADR-0017 (two-branch hierarchy), ADR-0036 (dimension objects),
ADR-0062 (normalisation at ingress and persistence), issue #2232 (the shape
duality), issue #2268 (remaining shadowing types), issues #1991 / #1992 (the
xfail markers repaired here).
Guidance: notes/domain-validation.md.