Per-Machine Dimension Objects for CaseStatus and ParticipantStatus
Context and Problem Statement
CaseStatus and ParticipantStatus each pack several independent state
machines into flat enum fields:
CaseStatus.em_state: EM— the case-level Embargo Management stateCaseStatus.pxa_state: CS_pxa— the public/exploit/attack case stateParticipantStatus.rm_state: RM— the per-participant Report Management stateParticipantStatus.vfd_state: CS_vfd— the per-participant vendor fix pathParticipantStatus.em_consent_state: PEC | None— per-participant embargo consent
These machines are genuinely independent, but their transition logic is
scattered: EmbargoLifecycle owns EM + PEC transitions, BT nodes own
RM/vfd transitions, and neither surface expresses what inputs each machine
can accept or what guards apply. Callers must know which external service
(or BT node, or inline EMAdapter) to call for each machine — there is no
single, self-describing owner.
The notes/lifecycle-staged-types.md design note and ADR-0033 both identify
this decomposition as a natural follow-on to lifecycle-staged types, with the
key observation: staged types make illegal shapes unrepresentable;
dimension objects make illegal transitions unrepresentable.
This ADR records the design choices made for that decomposition.
Decision Drivers
- Give each state machine one home: the dimension object both holds state and owns transition validation for that machine.
- Compose naturally with the predicate/state-group model from ADR-0033: guards
(
is_rm_validated(), etc.) become methods on the dimension object rather than free-standing helpers. - Do not proliferate identity-bearing objects — dimension objects are embedded
value objects inside
CaseStatus/ParticipantStatus, not independently persisted domain entities. - Preserve DataLayer round-trip compatibility; no stored "dimension type" discriminator.
- Keep the wire/AS2 projection consistent:
as_CaseStatusandas_ParticipantStatusproject dimension object fields to the wire format.
Considered Options
Option A — Lightweight Pydantic BaseModel dimension objects (chosen)
Each dimension is a small Pydantic model holding one state enum and owning
an immutable transition() method that validates the trigger, applies the
state machine rules, and returns a new dimension object with the updated state.
Guards (is_validated(), is_active(), etc.) are additional methods.
Names:
| Object | Replaces | Embedded in |
|---|---|---|
EmDimension |
CaseStatus.em_state: EM |
CaseStatus |
PxaDimension |
CaseStatus.pxa_state: CS_pxa |
CaseStatus |
RmDimension |
ParticipantStatus.rm_state: RM |
ParticipantStatus |
VfdDimension |
ParticipantStatus.vfd_state: CS_vfd |
ParticipantStatus |
PecDimension |
ParticipantStatus.em_consent_state: PEC \| None |
ParticipantStatus |
The naming *Dimension avoids collision with the existing VfdState and
PxaState NamedTuples in vultron/core/states/cs.py.
Option B — Full CoreObject subclasses with type_ and CORE_VOCABULARY entry
Dimension objects would be independently addressable domain objects with id_,
type_, and auto-registration in CORE_VOCABULARY.
Rejected: dimension objects are always embedded inside
CaseStatus/ParticipantStatus; there is no use case for querying or
persisting them independently. Adding CoreObject overhead for embedded value
objects bloats the schema and creates unnecessary CORE_VOCABULARY entries.
Option C — Property shims alongside existing flat enum fields
Add .em, .rm, etc. as computed properties returning temporary dimension
objects while keeping em_state, rm_state, etc. as the canonical fields.
Rejected: perpetuates the two-representation problem indefinitely and produces an extended half-migrated state.
Decision Outcome
Chosen option: Option A — lightweight Pydantic BaseModel dimension objects.
Governing principles
- Lightweight BaseModel, not CoreObject — dimension objects carry no
id_,type_, orCORE_VOCABULARYentry. They are embedded value objects owned by their enclosingCaseStatusorParticipantStatus. - Immutable
transition()— transitions return a new dimension object with the updated state; they never mutateself. This is consistent with Pydantic's design and with the append-only history model. - Replace, not alias — flat enum fields (
em_state,rm_state, etc.) are removed and replaced by embedded dimension object fields (em,rm, etc.). No shim properties. - Both layers in scope — domain model (
vultron/core/models/) and wire/AS2 projection (vultron/wire/as2/vocab/objects/case_status.py) are migrated in the same implementation. - Full call-site migration — all ~308 active-codebase call sites that
access the flat enum fields are updated.
vultron/bt/(legacy simulator) is excluded. - No stored data to migrate — there are no extant persisted records requiring backward-compat deserialization.
Consequences
- Good: each state machine has one home; the dimension object is the authoritative owner of transition validation and guard predicates.
- Good: type signatures express which machines a function works with
(
def accept_embargo(em: EmDimension) -> EmDimension). - Good: composable with staged types from ADR-0033 — predicates like
is_rm_validated()becomerm.is_validated()methods. - Neutral: call-site migration is large (~308 sites across ~82 files in
vultron/andtest/excludingvultron/bt/). - Neutral: the wire AS2 projection (
as_CaseStatus,as_ParticipantStatus) must updatefrom_core()/to_core()to handle nested dimension dicts.
Validation
RmDimension.transition()raises on an invalid trigger; returns newRmDimensionwith updated state on a valid one.EmDimension,PxaDimension,VfdDimension,PecDimensionfollow the same contract.CaseStatus/ParticipantStatusDataLayer round-trip: persist,dl.read(), and assert dimension objects are correctly rehydrated.- All 308 migrated call sites pass type-checker and test suite.
- Wire round-trip:
as_CaseStatus.from_core(core_status).to_core()produces an equivalentCaseStatuswith correct dimension values.
More Information
Related:
- ADR-0033 — Lifecycle-Staged Domain Types (predecessor; establishes the field-set governing principle and predicates model).
- ADR-0032 — Validate at the Edge, Promote to Strict Core Types.
- ADR-0017 — Domain/Wire Object Separation (staged types live on the core branch).
notes/lifecycle-staged-types.md§ "Future Direction: Per-Dimension Status Decomposition" — the trailhead for this decision.notes/status-dimension-objects.md— full design guidance and implementation notes.specs/status-dimension-objects.yaml— normative requirements (SDO-01 through SDO-04).-
1456 — source Idea.
-
1394 — parent Epic (Architecture Hardening).