ADR-036 Phase 2: TimedResource Abstraction Extraction β Implementation Plan¶
| Attribute | Value |
|---|---|
| ADR Reference | ADR-036 Β§3 Phase 2 |
| Architecture Reference | TimedResource Data Model |
| Target Sprint | F |
| Status | π In Progress (Phase 2 core β , Phase 2.5 Batches AβG β , Batch I β¬) |
| Author | Architecture Team |
| Date | 2026-03-09 |
1. Objective¶
Extract the three-layer Resource β TimedResource hierarchy into lcm_core,
creating the framework-promotable abstraction layer. All new types go into the
shared core package so that every microservice (CPA, worker-controller,
lablet-controller, resource-scheduler) can consume them.
Key Constraint: All changes MUST be backward-compatible. Existing aggregates gain base class inheritance but no behavioral changes. MongoDB serialization unchanged. All existing tests MUST pass without modification.
2. Context Analysis β Patterns to Follow¶
2.1 Value Object Pattern (from lcm_core)¶
Established by ResourceObservation, NodeObservation, InterfaceObservation:
@dataclass(frozen=True)β immutableto_dict() β dict[str, Any]β JSON-safe serialization@staticmethod from_dict(data: dict) β Selfβ deserialization with sensible defaults- Tuples for sequences (JSON serialized as lists)
- Explicit type annotations, no Pydantic
- Located in
lcm_core/domain/value_objects/
2.2 Read Model Pattern (from lcm_core)¶
Established by LabletSessionReadModel, CMLWorkerReadModel, LabRecordReadModel:
@dataclass(mutable β populated from API responses)@classmethod from_dict(cls, data: dict) β Self- Optional fields with sensible defaults
__post_init__for backward-compatible field mappings- Located in
lcm_core/domain/entities/read_models/
2.3 Test Pattern (from lcm_core/tests/)¶
Established by test_resource_observation_value_objects.py:
- Class-based test grouping:
class TestTimeslot: - Round-trip tests:
test_round_trip_full,test_round_trip_minimal - Edge-case tests: defaults, type coercion, boundary values
- Descriptive docstrings on every test method
- Direct imports from
lcm_core.domain.value_objects - No fixtures needed for simple VOs
2.4 Aggregate State Pattern (from CPA)¶
Established by LabletSessionState, CMLWorkerState, LabRecordState:
- Extends
AggregateState[str](Neuroglia) - All fields declared as class-level annotations (required for Neuroglia deserialization)
__init__sets defaults (not using dataclass β Neuroglia bypasses__init__on deserialization)@dispatch(EventType)handlers for event sourcing- Status as type-specific enum (e.g.,
CMLWorkerStatus)
2.5 StateTransition β Current Location¶
Currently lives in CPA only (control-plane-api/domain/value_objects/state_transition.py)
and is tightly coupled to LabletSessionStatus enum. ADR-036 requires a generic version
in lcm_core that uses str for states, while the CPA version can remain for backward
compatibility during migration.
3. Implementation Plan β Task Breakdown¶
Phase 2 Tasks (from ADR-036 Β§3)¶
| Task | Description | Component | Files |
|---|---|---|---|
| 2.1 | Verify/create StateTransition VO in lcm_core |
lcm_core | Β§3.1 |
| 2.2 | Create Timeslot VO in lcm_core |
lcm_core | Β§3.2 |
| 2.3 | Create LifecyclePhase + ManagedLifecycle VOs |
lcm_core | Β§3.3 |
| 2.4 | Create ResourceState abstract base |
lcm_core | Β§3.4 |
| 2.5 | Create TimedResourceState abstract base |
lcm_core | Β§3.5 |
| 2.6 | Create TimedResourceReadModel base |
lcm_core | Β§3.6 |
| 2.7 | Refactor LabletSessionState to extend TimedResourceState |
CPA | Β§3.7 |
| 2.8 | Add state_history to CMLWorkerState |
CPA | Β§3.8 |
| 2.9 | Add desired_status to LabRecordState |
CPA | Β§3.9 |
| 2.10 | Update LabletSessionReadModel to extend TimedResourceReadModel |
lcm_core | Β§3.10 |
| 2.11 | Update CMLWorkerReadModel to extend TimedResourceReadModel |
lcm_core | Β§3.11 |
| 2.12 | Tests for all VOs + base classes (40+ tests) | lcm_core | Β§3.12 |
| 2.13 | Backward compatibility β all existing tests pass | all | Β§3.13 |
3.1 Task 2.1: StateTransition Value Object in lcm_core¶
Goal: Create a generic StateTransition VO that uses str for states
(not LabletSessionStatus), making it usable by CMLWorker and LabRecord too.
File: src/core/lcm_core/domain/value_objects/state_transition.py (NEW)
Design:
@dataclass(frozen=True)
class StateTransition:
from_state: str | None # None for initial creation
to_state: str
transitioned_at: datetime
triggered_by: str # User ID, component, or "system"
reason: str | None = None
metadata: dict[str, Any] | None = None
def to_dict() β dict
@staticmethod from_dict(data) β StateTransition
Pattern alignment:
- Follows
ResourceObservationpattern: frozen dataclass,to_dict/from_dict - Uses
strfor states (not enum) β enum-specific conversion handled by concrete aggregates - The existing CPA
StateTransition(usesLabletSessionStatus) will be updated to delegate to or wrap this generic version in a later migration step
Backward compatibility: The CPA StateTransition continues to work unchanged.
The lcm_core version is a NEW parallel class used by the new base classes.
Update required: lcm_core/domain/value_objects/__init__.py β add export
3.2 Task 2.2: Timeslot Value Object¶
File: src/core/lcm_core/domain/value_objects/timeslot.py (NEW)
Design (from ADR-036 Β§2.1.4):
@dataclass(frozen=True)
class Timeslot:
start: datetime
end: datetime
lead_time: timedelta = timedelta(minutes=15)
teardown_buffer: timedelta = timedelta(minutes=10)
# Computed properties
@property provision_at β datetime # start - lead_time
@property cleanup_deadline β datetime # end + teardown_buffer
@property duration β timedelta # end - start
@property total_duration β timedelta # cleanup_deadline - provision_at
# Query methods
def is_active(now=None) β bool # start <= now <= end
def is_expired(now=None) β bool # now > end
def remaining(now=None) β timedelta # max(0, end - now)
# Factory
def extend(new_end) β Timeslot # Returns new Timeslot with extended end
def to_dict() β dict
@staticmethod from_dict(data) β Timeslot
Validation in __post_init__:
endmust be afterstartlead_timemust be non-negativeteardown_buffermust be non-negative
Pattern alignment: Follows ResourceObservation pattern exactly.
Update required: lcm_core/domain/value_objects/__init__.py β add export
3.3 Task 2.3: LifecyclePhase + ManagedLifecycle Value Objects¶
File: src/core/lcm_core/domain/value_objects/managed_lifecycle.py (NEW)
Design (from ADR-036 Β§2.1.4):
@dataclass(frozen=True)
class LifecyclePhase:
name: str
engine: str = "pipeline" # "pipeline" | "workflow"
trigger_on_status: str | None = None
pipeline_def: dict | None = None # Step definitions for PipelineExecutor
workflow_ref: dict | None = None # {namespace, name, version}
is_required: bool = True
def to_dict() β dict
@staticmethod from_dict(data) β LifecyclePhase
@dataclass(frozen=True)
class ManagedLifecycle:
phases: tuple[LifecyclePhase, ...] # Ordered, immutable sequence
current_phase: str | None = None
def get_phase(name: str) β LifecyclePhase | None
def get_active_phases() β list[LifecyclePhase] # where is_required=True
def phase_names() β list[str]
def to_dict() β dict
@staticmethod from_dict(data) β ManagedLifecycle
Design decision β phases field type:
The ADR shows dict[str, LifecyclePhase] but the established VO pattern in lcm_core
uses tuples for sequences (see ResourceObservation.nodes: tuple[NodeObservation, ...]).
However, phases need key-based lookup by name, so we use tuple[LifecyclePhase, ...]
for immutability with a lookup-by-name method. The to_dict serializes phases as a
dict keyed by name for YAML/JSON readability.
Update required: lcm_core/domain/value_objects/__init__.py β add exports
3.4 Task 2.4: ResourceState Abstract Base¶
File: src/core/lcm_core/domain/entities/resource.py (NEW)
Design (from ADR-036 Β§2.1.4 Layer 1):
class ResourceState(AggregateState[str]):
"""Base state for all managed resources (Kubernetes-like spec/status)."""
id: str
resource_type: str
status: str
desired_status: str | None
owner_id: str
state_history: list[StateTransition]
pipeline_progress: dict | None
created_at: datetime
updated_at: datetime
def __init__(self):
# Set defaults following the established AggregateState pattern
Critical design decisions:
-
statusanddesired_statusasstrβ NOT enums at this level. Each concrete aggregate uses its own enum type. The base class storesstrfor polymorphism. Concrete aggregates continue to use typed enums in their__init__and@dispatchhandlers β they coerce tostrwhen the base class needs it (or the base class simply declaresstrannotations and the Neuroglia serializer handles enumβstr). -
state_history: list[StateTransition]β Uses the new lcm_coreStateTransition(str-based). Concrete aggregates can wrap with type-specific transitions if needed. -
NOT a Python ABC β
AggregateStateis a Neuroglia class that doesn't supportABC/abstractmethod. We use documentation and type annotations to signal "abstract".
Update required: lcm_core/domain/entities/__init__.py β add export
3.5 Task 2.5: TimedResourceState Abstract Base¶
File: src/core/lcm_core/domain/entities/timed_resource.py (NEW)
Design (from ADR-036 Β§2.1.4 Layer 2):
class TimedResourceState(ResourceState):
"""State for time-bounded resources with managed lifecycles."""
timeslot: Timeslot | None # When this resource is active
lifecycle: ManagedLifecycle | None # Phases and their execution strategies
started_at: datetime | None
ended_at: datetime | None
duration_seconds: float | None
terminated_at: datetime | None
def __init__(self):
super().__init__()
# Set timed-resource specific defaults
Design note on Neuroglia serialization: The Neuroglia MotorRepository serializes
aggregate state using get_type_hints() and walks the class hierarchy. Adding a base
class with type-annotated fields is safe as long as:
- All new fields have defaults in
__init__ - No required constructor args are added
- Field names don't conflict with existing aggregate fields
Timeslot storage: The Timeslot VO is stored as a serialized dict in MongoDB.
Concrete aggregates that currently use timeslot_start/timeslot_end as separate
fields will keep those fields during migration (Phase 2) and derive the Timeslot
VO from them. The full migration to Timeslot-only storage happens in a later phase.
Update required: lcm_core/domain/entities/__init__.py β add export
3.6 Task 2.6: TimedResourceReadModel Base¶
File: src/core/lcm_core/domain/entities/read_models/timed_resource_read_model.py (NEW)
Design:
@dataclass
class TimedResourceReadModel:
"""Base read model for time-bounded resources."""
id: str
resource_type: str = ""
status: str = ""
desired_status: str | None = None
owner_id: str = ""
# Timeslot fields (flat for backward compat, not Timeslot VO)
timeslot_start: datetime | None = None
timeslot_end: datetime | None = None
# Runtime
started_at: datetime | None = None
ended_at: datetime | None = None
duration_seconds: float | None = None
terminated_at: datetime | None = None
# Lifecycle
pipeline_progress: dict | None = None
# Timestamps
created_at: datetime | None = None
updated_at: datetime | None = None
Design decision: The read model keeps timeslot_start/timeslot_end as flat
fields (not a Timeslot VO) because read models are DTOs from API responses where
the timeslot is not yet consolidated into a VO at the API level.
Update required: lcm_core/domain/entities/read_models/__init__.py β add export
3.7 Task 2.7: Refactor LabletSessionState to Extend TimedResourceState¶
File: src/control-plane-api/domain/entities/lablet_session.py (MODIFY)
Strategy: Introduce inheritance WITHOUT changing any behavior.
Changes:
- Add import:
from lcm_core.domain.entities.timed_resource import TimedResourceState - Change:
class LabletSessionState(AggregateState[str]):βclass LabletSessionState(TimedResourceState): - In
__init__: Callsuper().__init__()which now chains throughTimedResourceState β ResourceState β AggregateState - Keep all existing field annotations β they override/augment base class fields
- The
statusfield remainsLabletSessionStatus(enum) at the concrete level β the base class declaresstr, Python's MRO resolves to the concrete annotation
CRITICAL RISK β Field Shadowing:
Fields declared in LabletSessionState that overlap with ResourceState/TimedResourceState:
idβ already in ResourceState. Keep in LabletSessionState (shadows base, same type)statusβ ResourceState declaresstr, LabletSessionState declaresLabletSessionStatus. Safe: concrete overrides abstractdesired_statusβ same pattern asstatusstate_historyβ ResourceState declareslist[StateTransition](lcm_core version). LabletSessionState uses CPA version. RISK: Type mismatch. Mitigation: During Phase 2, keep the LabletSessionState field as-is; it shadows the base. The base field is only used by NEW aggregates.pipeline_progressβ same type, safecreated_at,started_at,ended_at,terminated_at,duration_secondsβ safe, same types
Approach: Shadow all overlapping fields in the concrete class. This is the safest backward-compatible approach. The base class provides the structural contract; concrete classes provide the actual implementation.
Validation: Run full CPA test suite β zero test modifications expected.
3.8 Task 2.8: Add state_history to CMLWorkerState¶
File: src/control-plane-api/domain/entities/cml_worker.py (MODIFY)
Changes:
- Add field annotation:
state_history: list(use generic list for now) - In
__init__:self.state_history = [] - Add
_record_transition()method (copy pattern from LabletSessionState) - In the
on(CMLWorkerStatusUpdatedDomainEvent)handler: call_record_transition()
Scope: This task adds the field and recording capability. It does NOT change CMLWorkerState's base class (that's a later step when all fields are aligned).
Why not change base class now?: CMLWorkerState has significantly more fields and domain-specific behavior. Changing its base class requires careful validation that Neuroglia deserialization still works. This is lower risk as a standalone field addition.
3.9 Task 2.9: Add desired_status to LabRecordState¶
File: src/control-plane-api/domain/entities/lab_record.py (MODIFY)
Changes:
- Add field annotation:
desired_status: str | None - In
__init__:self.desired_status = None - Add domain event:
LabRecordDesiredStatusSetDomainEventindomain/events/lab_record_events.py - Add
set_desired_status()method on aggregate - Add
@dispatch(LabRecordDesiredStatusSetDomainEvent)handler on state
Scope: Field addition only. No base class change.
3.10 Task 2.10: Update LabletSessionReadModel¶
File: src/core/lcm_core/domain/entities/read_models/lablet_session_read_model.py (MODIFY)
Strategy: Make LabletSessionReadModel extend TimedResourceReadModel.
Changes:
- Add import:
from lcm_core.domain.entities.read_models.timed_resource_read_model import TimedResourceReadModel - Change:
class LabletSessionReadModel:βclass LabletSessionReadModel(TimedResourceReadModel): - Remove fields that are now inherited:
id,status,desired_status,timeslot_start,timeslot_end,started_at,ended_at,pipeline_progress - Keep LabletSession-specific fields
- Update
from_dictto pass base fields toTimedResourceReadModel
RISK: Field removal changes __init__ parameter order for positional args.
Mitigation: LabletSessionReadModel already uses keyword arguments everywhere
(fields have defaults). The from_dict factory is the primary construction path.
Validation: All existing tests and consumers use from_dict() or keyword construction.
3.11 Task 2.11: Update CMLWorkerReadModel¶
File: src/core/lcm_core/domain/entities/read_models/cml_worker_read_model.py (MODIFY)
Strategy: Same as 3.10 but for CMLWorkerReadModel.
Changes:
- Add import for
TimedResourceReadModel - Change base class
- Remove inherited fields:
id,status,desired_status - Note: CMLWorkerReadModel does NOT currently have
timeslot_start/timeslot_endfields (CMLWorker doesn't track timeslots yet). The inherited fields fromTimedResourceReadModelwill default toNone.
Validation: Existing from_dict factory method handles all fields explicitly.
3.12 Task 2.12: Test Suite (40+ Tests)¶
Files: NEW test files in src/core/tests/
3.12.1 test_state_transition_value_object.py¶
| # | Test | What it validates |
|---|---|---|
| 1 | test_round_trip_full |
All fields populated β from_dict(to_dict(x)) == x |
| 2 | test_round_trip_minimal |
from_state=None, reason=None, metadata=None |
| 3 | test_from_dict_none_from_state |
from_state absent in dict β None |
| 4 | test_to_dict_transitioned_at_isoformat |
Datetime serialized as ISO string |
| 5 | test_from_dict_parses_iso_datetime |
ISO string deserialized to datetime |
| 6 | test_frozen_immutability |
Cannot mutate fields after creation |
| 7 | test_metadata_preserved |
Arbitrary dict metadata round-trips |
3.12.2 test_timeslot_value_object.py¶
| # | Test | What it validates |
|---|---|---|
| 1 | test_round_trip_full |
All fields with custom lead_time/teardown |
| 2 | test_round_trip_defaults |
Default lead_time (15min) and teardown (10min) |
| 3 | test_provision_at |
provision_at == start - lead_time |
| 4 | test_cleanup_deadline |
cleanup_deadline == end + teardown_buffer |
| 5 | test_duration |
duration == end - start |
| 6 | test_total_duration |
total_duration == cleanup_deadline - provision_at |
| 7 | test_is_active_within_window |
is_active(now) returns True when start <= now <= end |
| 8 | test_is_active_before_start |
is_active(now) returns False before start |
| 9 | test_is_active_after_end |
is_active(now) returns False after end |
| 10 | test_is_expired |
is_expired(now) returns True after end |
| 11 | test_is_not_expired |
is_expired(now) returns False before end |
| 12 | test_remaining_within_window |
Correct remaining time calculation |
| 13 | test_remaining_after_end |
Returns timedelta(0) after expiry |
| 14 | test_extend_valid |
Returns new Timeslot with extended end |
| 15 | test_extend_invalid_raises |
Raises ValueError if new_end <= current end |
| 16 | test_validation_end_before_start |
__post_init__ raises if end <= start |
| 17 | test_validation_negative_lead_time |
__post_init__ raises if lead_time < 0 |
| 18 | test_frozen_immutability |
Cannot mutate fields |
| 19 | test_to_dict_timedelta_as_seconds |
Timedeltas serialized as float seconds |
| 20 | test_from_dict_timedelta_from_seconds |
Timedeltas deserialized from float seconds |
3.12.3 test_managed_lifecycle_value_objects.py¶
| # | Test | What it validates |
|---|---|---|
| 1 | test_lifecycle_phase_round_trip_full |
LifecyclePhase with all fields |
| 2 | test_lifecycle_phase_round_trip_minimal |
LifecyclePhase with defaults only |
| 3 | test_lifecycle_phase_pipeline_engine |
Engine defaults to "pipeline" |
| 4 | test_lifecycle_phase_workflow_engine |
Engine = "workflow" with workflow_ref |
| 5 | test_managed_lifecycle_round_trip |
Full ManagedLifecycle round-trip |
| 6 | test_managed_lifecycle_empty_phases |
Empty phases tuple |
| 7 | test_get_phase_found |
Returns correct phase by name |
| 8 | test_get_phase_not_found |
Returns None for unknown name |
| 9 | test_get_active_phases |
Filters by is_required=True |
| 10 | test_phase_names |
Returns ordered list of phase names |
| 11 | test_frozen_immutability |
Cannot mutate fields |
| 12 | test_to_dict_phases_as_dict |
Phases serialized as dict keyed by name |
| 13 | test_from_dict_phases_from_dict |
Phases deserialized from dict keyed by name |
3.12.4 test_timed_resource_read_model.py¶
| # | Test | What it validates |
|---|---|---|
| 1 | test_base_fields_accessible |
TimedResourceReadModel instantiation with all fields |
| 2 | test_defaults |
All optional fields default correctly |
| 3 | test_lablet_session_inherits_base |
LabletSessionReadModel is instance of TimedResourceReadModel |
| 4 | test_lablet_session_from_dict_preserves_base_fields |
Base fields populated via from_dict |
| 5 | test_cml_worker_inherits_base |
CMLWorkerReadModel is instance of TimedResourceReadModel |
| 6 | test_cml_worker_from_dict_preserves_base_fields |
Base fields populated via from_dict |
| 7 | test_backward_compat_lablet_session_all_fields |
All existing LabletSessionReadModel fields still work |
| 8 | test_backward_compat_cml_worker_all_fields |
All existing CMLWorkerReadModel fields still work |
3.13 Task 2.13: Backward Compatibility Verification¶
Actions:
- Run
cd src/core && make testβ all lcm_core tests pass - Run
cd src/control-plane-api && make testβ all CPA tests pass - Run
cd src/lablet-controller && make testβ all lablet-controller tests pass - Run
cd src/worker-controller && make testβ all worker-controller tests pass - Run
cd src/resource-scheduler && make testβ all resource-scheduler tests pass
Pass criteria: Zero test modifications. If any test fails, the change is NOT backward-compatible and must be redesigned.
4. File Tree β All New & Modified Files¶
src/core/lcm_core/domain/
βββ value_objects/
β βββ __init__.py β MODIFY: add exports
β βββ state_transition.py β NEW (Task 2.1)
β βββ timeslot.py β NEW (Task 2.2)
β βββ managed_lifecycle.py β NEW (Task 2.3)
βββ entities/
β βββ __init__.py β MODIFY: add exports
β βββ resource.py β NEW (Task 2.4)
β βββ timed_resource.py β NEW (Task 2.5)
β βββ read_models/
β βββ __init__.py β MODIFY: add export
β βββ timed_resource_read_model.py β NEW (Task 2.6)
β βββ lablet_session_read_model.py β MODIFY (Task 2.10)
β βββ cml_worker_read_model.py β MODIFY (Task 2.11)
src/control-plane-api/domain/
βββ entities/
β βββ lablet_session.py β MODIFY (Task 2.7) β NOT in Phase 2
β βββ cml_worker.py β MODIFY (Task 2.8) β state_history only
β βββ lab_record.py β MODIFY (Task 2.9) β desired_status only
βββ events/
β βββ lab_record_events.py β MODIFY (Task 2.9) β add event
src/core/tests/
βββ test_state_transition_value_object.py β NEW (Task 2.12.1)
βββ test_timeslot_value_object.py β NEW (Task 2.12.2)
βββ test_managed_lifecycle_value_objects.py β NEW (Task 2.12.3)
βββ test_timed_resource_read_model.py β NEW (Task 2.12.4)
5. Implementation Order (Dependency Chain)¶
Tasks must be implemented in this order due to dependencies:
2.1 StateTransition VO ββββββββββββββββββββββ
β
2.2 Timeslot VO ββββββββββββββββββββββββββββββ€
β
2.3 LifecyclePhase + ManagedLifecycle VOs ββββ€
β
βΌ
2.4 ResourceState (depends on 2.1) βββββββββββ€
β
βΌ
2.5 TimedResourceState (depends on 2.2, 2.3, 2.4) βββ
β
2.6 TimedResourceReadModel (independent) ββββββββββββββ€
β
βββββββββ€
β β
2.7 LabletSessionState refactor βββββββββββββββ β These 3 are independent
2.8 CMLWorkerState state_history ββββββββββββββββββββββ€ of each other but
2.9 LabRecordState desired_status βββββββββββββββββββββ depend on 2.4/2.5
β
2.10 LabletSessionReadModel refactor ββββββββββββββββββ€ Depend on 2.6
2.11 CMLWorkerReadModel refactor ββββββββββββββββββββββ
β
2.12 Tests (can start in parallel with each task) βββββ
β
2.13 Full backward compatibility verification βββββββββ
Recommended Implementation Batches¶
| Batch | Tasks | Rationale | Status |
|---|---|---|---|
| Batch A | 2.1, 2.2, 2.3 | Value Objects β no dependencies, can be developed and tested independently | β Complete |
| Batch B | 2.4, 2.5 | Entity base classes β depend on Batch A VOs | β Complete |
| Batch C | 2.6 | Read model base β independent of B but logically follows | β Complete |
| Batch D | 2.8, 2.9 | CPA aggregate field additions β low risk, independent of each other | β Complete |
| Batch E | 2.7 | LabletSessionState refactor β highest risk, done after D validates patterns | β Complete (original scope: field additions only) |
| Batch F | 2.10, 2.11 | Read model refactors β depend on C | β Complete |
| Batch G | 2.12, 2.13 | LabRecordState β ResourceState + testing and validation | β Complete |
| Batch I | 2.14 | LabletDefinitionState β TimedResourceState (Layer 2) β last aggregate promotion | β¬ Not Started |
6. Risk Assessment¶
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
Neuroglia get_type_hints() doesn't walk MRO for inherited fields |
High | Low | Test with minimal prototype before full implementation |
LabletSessionState field shadowing causes serialization issues |
High | Medium | Shadow ALL overlapping fields in concrete class; test MongoDB round-trip |
Read model base class changes __init__ parameter order |
Medium | Low | All construction uses keyword args or from_dict factory |
CMLWorkerState state_history field breaks existing MongoDB documents |
Medium | Low | Default to [] in __init__; Neuroglia sets missing fields to type default |
Timeslot VO complicates timeslot_start/timeslot_end migration |
Low | Medium | Phase 2 keeps flat fields; Timeslot VO is parallel, not replacing |
7. Acceptance Criteria¶
- β
All 3 new VOs (
StateTransition,Timeslot,ManagedLifecycle) implemented withto_dict/from_dict - β
ResourceStateandTimedResourceStatebase classes inlcm_core - β
TimedResourceReadModelbase class inlcm_core - β
LabletSessionReadModelandCMLWorkerReadModelextendTimedResourceReadModel - β
CMLWorkerStatehasstate_historyfield - β
LabRecordStatehasdesired_statusfield with domain event - β 40+ unit tests covering all new code
- β All existing tests pass without modification across all 5 microservices
- β
lcm_core/domain/value_objects/__init__.pyandlcm_core/domain/entities/__init__.pyupdated with exports
8. Deferred to Later Phases¶
The following are explicitly NOT in Phase 2 scope:
| Item | Deferred to | Reason | Status |
|---|---|---|---|
Change CMLWorkerState base class to TimedResourceState |
Phase 2.5 Batch E | High risk; needs dedicated MongoDB round-trip testing | β Complete (AD-P2-E01) |
Change LabRecordState base class to ResourceState (Layer 1) |
Phase 2.5 Batch G | LabRecords have open-ended lifetimes, no timeslots β ResourceState (not TimedResourceState) per AD-G0 | β Complete (AD-G0) |
Migrate LabletSessionState.timeslot_start/end β Timeslot VO |
Phase 2.5 Batch F | Requires API-level changes and frontend updates | β Complete (AD-P2-F01) |
Replace CPA StateTransition with lcm_core version |
Phase 2.5 Batch F | Requires updating all CPA event handlers | β Complete (AD-P2-F01) |
Change LabletDefinitionState base class to TimedResourceState (Layer 2) |
Phase 2.5 Batch I | Last remaining aggregate extending raw AggregateState[str]. Timeslot-bounded definitions enable automatic expiry and time-windowed availability for instantiation. Per AD-I0. |
β¬ Not Started |
| Frontend pipeline progress components (ADR-036 1.12β1.13) | Phase 1 (frontend sprint) | Independent of backend abstraction | π² Not Started |
LabletSessionState base class change to TimedResourceState |
Phase 2.5 Batch F | Validate base classes work first, then refactor | β Complete (AD-P2-F01) |
Note on Task 2.7 (LabletSessionState refactor): Upon careful review, changing the
base class of LabletSessionState from AggregateState[str] to TimedResourceState
carried significant risk due to field shadowing with different types (LabletSessionStatus
vs str). This was deferred to Phase 2.5 after validating the base classes work
correctly with NEW aggregates (like DemoSession in Phase 4). In Phase 2, the base classes
were created and tested but not yet inherited by existing aggregates.
β Phase 2.5 Completion Status (2026-03-09):
Batch E (CMLWorkerState β TimedResourceState): Complete. CMLWorkerState base class changed, CML_WORKER_LIFECYCLE defined in
domain/lifecycles.py, all_record_transition()handlers updated with.valueenum-to-str conversion, backward-compatible Timeslot properties added. 1070 CPA tests pass, 263 lcm_core tests pass. (AD-P2-E01, AD-P2-E02)Batch F (LabletSessionState β TimedResourceState): Complete. LabletSessionState base class changed, LABLET_SESSION_LIFECYCLE (10 phases) defined,
_record_transition()stores dicts viaStateTransition.to_dict(), all 22 event handlers updated, CPA-localStateTransitionimports consolidated tolcm_core, backward-compatibletimeslot_start/timeslot_endproperties with legacy fallback. 46 new tests, 929 CPA tests pass (+2 pre-existing failures), 263 lcm_core tests pass, lint clean. (AD-P2-F01, AD-P2-F02)Batch G (LabRecordState β ResourceState): Complete. LabRecordState base class changed to ResourceState (Layer 1, not TimedResourceState) per AD-G0 β LabRecords have open-ended lifetimes with no timeslots or lifecycle phases.
_record_transition()wired into all 13 status-changing @dispatch handlers with conditional transitions for idempotent events. CPA-localstate_transition.pydeleted, imports consolidated tolcm_core. 38 new tests, 1108 CPA tests pass (+2 pre-existing failures), 263 lcm_core tests pass, lint clean. (AD-G0)Key patterns established: Dict-based state_history (via
StateTransition.to_dict()), backward-compatible@propertyaccessors for Timeslot VO fields,ManagedLifecyclewiring in aggregateCreatedevent handlers.Batch I (LabletDefinitionState β TimedResourceState): β¬ Not Started. Last remaining aggregate extending raw
AggregateState[str]. Will promote to Layer 2 (TimedResourceState) per AD-I0 β definitions are time-bounded templates that expire when their timeslot ends. Key design decisions:- Layer 2 (not Layer 1): Definitions need timeslot (time-bounded availability) and managed lifecycle (content sync pipeline tracking)
desired_status: Initially unused (None); reserved for future definition reconciliation via a definition controller (similar to worker-controller pattern)created_byβowner_id: Mapped in DefinitionCreated event handler; futureset-definition-ownercommand planned when user profile management is available- Timeslot semantics: Definitions are only available for instantiation during their valid timeslot window. Authorized users can extend timeslots via
Timeslot.extend(). Expired definitions should transition to ARCHIVED or a new EXPIRED status.- Status enum expansion: Consider adding EXPIRED to
LabletDefinitionStatusfor timeslot-driven automatic expiry (vs manual DEPRECATED)- Scope estimate: ~60% of Batch G effort (fewer event handlers, no pending_action pattern)
P2-FINAL: Documentation Maintenance (Mandatory)¶
Per AD-DOC-001, every implementation phase includes a final documentation task:
- [x] Update this plan: mark Phase 2 tasks complete, record actual vs planned
- [ ] Update ADR-036: Phase 2 status β DONE
- [ ] Update implementation_status.md if it exists
- [x] Update CHANGELOG.md with Phase 2 changes under "Unreleased"