Skip to content

LabRecord as Independent Aggregate β€” Architecture DesignΒΆ

Attribute Value
Document Version 1.0.0
Status Proposed
Created 2026-02-10
Author Architecture Team
Related Lablet Instance Lifecycle, Resource Manager Architecture, CML Telemetry Remediation
ADR ADR-019: LabRecord as Independent AggregateRoot (see ADR-019)

Table of ContentsΒΆ

  1. Executive Summary
  2. Problem Statement
  3. Domain Model
  4. 3.1 Core Entities
  5. 3.2 Value Objects
  6. 3.3 Domain Events
  7. 3.4 LabletRecordRun β€” Cross-Aggregate Runtime Execution Mapping
  8. 3.5 Entity Relationship Diagram
  9. LabRecord Aggregate Design
  10. Relationship Model: LabRecord ↔ LabletInstance
  11. LabRecord Lifecycle State Machine
  12. Discovery & Synchronisation
  13. Backend API Design
  14. 8.1 Public API (BFF β€” /api/lab-records/)
  15. 8.2 Internal API (Controller-to-CPA)
  16. 8.3 LabletInstance API Extensions
  17. 8.4 Worker API Extensions
  18. 8.5 CQRS Commands & Queries
  19. 8.6 SSE Events
  20. 8.7 LabletRecordRun API
  21. 8.8 LDS Session API
  22. 8.9 Grading API
  23. 8.10 LabletRecordRun CQRS Commands & Queries
  24. 8.11 Extended SSE Events (Run, LDS, Grading)
  25. Frontend Design
  26. 9.1 Session-Centric Navigation & Information Architecture
  27. 9.2 Sessions Page (/sessions)
  28. 9.3 Session Detail Page
  29. 9.4 Labs Management Page (/labs)
  30. 9.5 LDS Session Integration (IFRAME)
  31. 9.6 Grading Integration
  32. 9.7 LabletRecordRun Lifecycle in the UI
  33. 9.8 New Web Components
  34. 9.9 State Management Extensions
  35. 9.10 SSE Integration
  36. 9.11 UI API Client Extensions
  37. Implementation Gaps & Roadmap
  38. Migration Strategy

  39. Appendix A: CML Lab API Reference (v2.9)

  40. Appendix B: Topology YAML Schema Reference
  41. Appendix C: Files to Create/Modify
  42. Appendix D: External Domain Models Reference
  43. D.1 Session Domain
  44. D.2 Pod Domain
  45. D.3 Schedule Domain
  46. D.4 Form Content Packages
  47. D.5 Cross-Domain Relationship Map
  48. D.6 Grading Domain

1. Executive SummaryΒΆ

Runtime Environment = LabRecord <-> LabletInstance = Experience' Timeslot

This document proposes elevating LabRecord from a passive sync-snapshot of CML labs to a first-class, independent AggregateRoot with its own lifecycle, versioning, runtime abstraction, and many-to-many relationship with LabletInstance.

Key BenefitsΒΆ

Benefit Impact
Decoupled lab lifecycle Labs exist independently of lablet timeslots β€” dramatically reduces initialization delay
Lab reuse across timeslots Wipe-and-reset a warm lab in ~10s vs cold-import in ~90s (β‰ˆ9Γ— faster)
Multi-lab sessions One LabletInstance can reference multiple interconnected labs (multi-site topologies)
Runtime abstraction Labs can run on CML, Kubernetes pods, or bare-metal β€” common interface
Independent discovery Labs discovered on workers automatically, linkable to lablet instances on demand
Version history Track topology revisions, config drift, and operational history per lab

Architectural DecisionΒΆ

ADR-018: LabRecord SHALL be an independent AggregateRoot with its own repository, lifecycle state machine, and API surface. Its relationship to LabletInstance is managed through a join entity (LabletLabBinding), not through foreign keys on either aggregate. This preserves aggregate boundaries per DDD principles.


2. Problem StatementΒΆ

Current StateΒΆ

LabletDefinition (template)
        β”‚  1:N
        β–Ό
LabletInstance (runtime lifecycle)
        β”‚  owns exactly 1 cml_lab_id (string FK)
        β–Ό
LabRecord (passive snapshot, synced by LabsRefreshService every 30 min)
        β”‚  scoped to a single worker_id
        β–Ό
CML Lab (external, on a CML worker)

Problems:

  1. Tight coupling β€” LabletInstance.state.cml_lab_id is a bare string. The lab's own lifecycle (state, topology, nodes) is invisible to the lablet until the next 30-min sync.

  2. No lab reuse β€” Every LabletInstance cold-imports a fresh lab from YAML. On m5zn.metal instances, this takes 60–120s. For classes with 50 students running the same topology, that's 50 redundant imports.

  3. Single-lab assumption β€” Multi-site labs (e.g., campus + branch + datacenter) require multiple CML labs with inter-lab links. The current model can't represent this.

  4. No runtime abstraction β€” LabRecord assumes CML. Future runtimes (containerized labs on K8s, cloud-hosted pods) have no model.

  5. Discovery is fire-and-forget β€” LabsRefreshService syncs labs as dicts, but discovered labs can't be adopted by lablet instances without manual intervention.

  6. No versioning β€” Topology changes (node additions, config updates) aren't tracked. No diff, no rollback.

Desired StateΒΆ

LabletDefinition (template, immutable topology YAML)
        β”‚  1:N
        β–Ό
LabletInstance (workload lifecycle: scheduling, grading, LDS)
        β”‚  M:N  via LabletLabBinding
        β–Ό
LabRecord (independent lab lifecycle: import, start, stop, wipe, version)
        β”‚  1:1
        β–Ό
RuntimeEnvironment (CML worker, K8s pod, bare-metal)

3. Domain ModelΒΆ

3.1 Ubiquitous LanguageΒΆ

Term Definition
LabRecord An AggregateRoot representing a network lab topology instantiated in a runtime environment. It has its own lifecycle independent of any LabletInstance.
LabTopologySpec Value Object β€” the declarative YAML/JSON topology definition (nodes, links, annotations, metadata). Immutable per version.
RuntimeEnvironmentType Enum β€” the type of compute platform: CML, POD, K8S, BARE_METAL
RuntimeBinding Value Object β€” locates a lab in its runtime: CmlWorker(worker_id, lab_id), KubernetesPod(cluster, namespace, pod), etc.
LabletLabBinding Join Entity β€” formalises the M:N relationship between LabletInstance and LabRecord, with role and lifecycle tracking.
ExternalInterface Value Object β€” a protocol/port pair exposed by a lab node to the outside world (e.g., serial:5041, vnc:5044, ssh:22).
LabRevision Value Object β€” a numbered revision of a LabRecord's topology with timestamp and changelog.
LabRunRecord Value Object — historical record of a single "run" (start→stop cycle) with duration, operator, and outcome.

3.2 Session (Parent Container) ModelΒΆ

The UI and domain model should treat Session as the top-level experience container, aligning with other microservices that manage Sessions, SessionParts, Pods, and Content. A LabletInstance becomes an optional child component bound to SessionItems within a SessionPart.

Session (parent container)
    β”œβ”€β”€ SessionPart (content-scoped segment)
    β”‚     β”œβ”€β”€ SessionItem (activity/unit within the part)
    β”‚     β”‚     β”œβ”€β”€ optional LabletInstance (lab runtime child)
    β”‚     β”‚     └── optional LabRecord binding(s)
    β”‚     └── workflows (initial_state, item_transition, collect_and_grade, validate_score_report)
    └── metadata (owner, timeslot, hosting site, location)

Core concepts:

Concept Definition Notes
Session Top-level runtime experience container that spans one or more SessionParts Owned by Session microservice; LCM consumes via API/events
SessionPart A content-scoped segment (e.g., module or track) Linked to external content definitions
SessionItem Atomic activity within a SessionPart May map to a lab, quiz, or external activity
LabletInstance Optional lab runtime child bound to one or more SessionItems Timeslot + grading lifecycle remains in LCM
LabRecord Independent lab asset; can be bound to SessionItems via LabletInstance Enables reuse across SessionParts

Implication: The user-facing nav should emphasize Sessions (not Lablets), and lab bindings should be expressed in SessionItem context (e.g., "Session Item β†’ LabletInstance β†’ LabRecord(s)").

Integration Note: Sessions, SessionParts, Pods, and Content are managed by separate microservices with rich OpenAPI and CloudEvents. LCM should consume these APIs/events to resolve Session metadata (timeslot, hosting site, location) and to publish lab lifecycle updates back into the session event stream.

3.3 Aggregate BoundariesΒΆ

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LabRecord Aggregate                                                         β”‚
β”‚                                                                              β”‚
β”‚  LabRecordState                                                              β”‚
β”‚  β”œβ”€β”€ id: str (globally unique, e.g., UUID)                                  β”‚
β”‚  β”œβ”€β”€ title: str                                                              β”‚
β”‚  β”œβ”€β”€ description: str                                                        β”‚
β”‚  β”œβ”€β”€ status: LabRecordStatus (enum - own lifecycle)                         β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Topology ───                                                        β”‚
β”‚  β”‚   β”œβ”€β”€ topology_spec: LabTopologySpec (current version)                   β”‚
β”‚  β”‚   β”œβ”€β”€ node_count: int                                                     β”‚
β”‚  β”‚   β”œβ”€β”€ link_count: int                                                     β”‚
β”‚  β”‚   └── external_interfaces: list[ExternalInterface]                       β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Runtime ───                                                         β”‚
β”‚  β”‚   β”œβ”€β”€ runtime_type: RuntimeEnvironmentType                               β”‚
β”‚  β”‚   β”œβ”€β”€ runtime_binding: RuntimeBinding (worker_id + runtime-specific ref) β”‚
β”‚  β”‚   └── runtime_lab_id: str (CML lab ID, pod name, etc.)                  β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Versioning ───                                                      β”‚
β”‚  β”‚   β”œβ”€β”€ revision: int (monotonic)                                           β”‚
β”‚  β”‚   β”œβ”€β”€ revision_history: list[LabRevision] (max 50)                       β”‚
β”‚  β”‚   └── based_on_definition_id: str | None (if created from definition)   β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Ownership & Provenance ───                                          β”‚
β”‚  β”‚   β”œβ”€β”€ owner_username: str                                                 β”‚
β”‚  β”‚   β”œβ”€β”€ source: str ("discovery", "import", "clone", "lablet-controller")  β”‚
β”‚  β”‚   β”œβ”€β”€ first_seen_at: datetime                                             β”‚
β”‚  β”‚   └── last_synced_at: datetime                                            β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Operational ───                                                     β”‚
β”‚  β”‚   β”œβ”€β”€ run_history: list[LabRunRecord] (max 100)                          β”‚
β”‚  β”‚   β”œβ”€β”€ pending_action: str | None                                          β”‚
β”‚  β”‚   β”œβ”€β”€ pending_action_at: datetime | None                                  β”‚
β”‚  β”‚   └── pending_action_error: str | None                                    β”‚
β”‚  β”‚                                                                           β”‚
β”‚  └── ─── Sync Metadata ───                                                   β”‚
β”‚      β”œβ”€β”€ cml_created_at: datetime                                            β”‚
β”‚      β”œβ”€β”€ cml_modified_at: datetime                                           β”‚
β”‚      β”œβ”€β”€ groups: list[str]                                                   β”‚
β”‚      └── notes: str                                                          β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LabletInstance Aggregate (EXISTING β€” modifications highlighted)             β”‚
β”‚                                                                              β”‚
β”‚  LabletInstanceState                                                         β”‚
β”‚  β”œβ”€β”€ id, definition_id, definition_name, definition_version                 β”‚
β”‚  β”œβ”€β”€ owner_id, reservation_id, timeslot_start, timeslot_end                 β”‚
β”‚  β”œβ”€β”€ status: LabletInstanceStatus (unchanged state machine)                 β”‚
β”‚  β”œβ”€β”€ state_history: list[StateTransition]                                    β”‚
β”‚  β”œβ”€β”€ worker_id, allocated_ports                                              β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ cml_lab_id: str | None  ← DEPRECATED (kept for backward compat)       β”‚
β”‚  β”œβ”€β”€ lab_bindings: list[str]  ← NEW: list of LabletLabBinding IDs          β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ lds_session_id, lds_login_url                                           β”‚
β”‚  β”œβ”€β”€ grading_score, grading_rules_uri                                        β”‚
β”‚  └── timestamps...                                                           β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LabletLabBinding (Join Entity β€” stored in its own collection)              β”‚
β”‚                                                                              β”‚
β”‚  β”œβ”€β”€ id: str (UUID)                                                          β”‚
β”‚  β”œβ”€β”€ lablet_instance_id: str (FK β†’ LabletInstance)                          β”‚
β”‚  β”œβ”€β”€ lab_record_id: str (FK β†’ LabRecord)                                    β”‚
β”‚  β”œβ”€β”€ role: BindingRole ("primary", "secondary", "auxiliary")                β”‚
β”‚  β”œβ”€β”€ bound_at: datetime                                                      β”‚
β”‚  β”œβ”€β”€ unbound_at: datetime | None                                             β”‚
β”‚  β”œβ”€β”€ is_active: bool                                                         β”‚
β”‚  └── metadata: dict                                                          β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3.4 LabletRecordRun β€” The Runtime Execution MappingΒΆ

A LabletRecordRun captures the operational intersection between a LabletInstance (timeslot/experience) and a LabRecord (runtime lab) within the context of a SessionPart. It is the concrete runtime execution record that links:

  • Who β€” which LabletInstance (scheduled timeslot)
  • What β€” which LabRecord (CML lab with topology and nodes)
  • When β€” start/end of the actual runtime window within the timeslot
  • Where β€” which CML Worker, with resolved port mappings
  • Why β€” which SessionPart + FormQualifiedName drove the instantiation
  • How β€” the LDS Session provisioned, grading sessions triggered, score reports produced
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LabletRecordRun (Join Value Object / Mapping Entity)                       β”‚
β”‚                                                                              β”‚
β”‚  β”œβ”€β”€ id: str (UUID)                                                          β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Identity References ───                                             β”‚
β”‚  β”‚   β”œβ”€β”€ lablet_instance_id: str (FK β†’ LabletInstance)                      β”‚
β”‚  β”‚   β”œβ”€β”€ lab_record_id: str (FK β†’ LabRecord)                                β”‚
β”‚  β”‚   β”œβ”€β”€ lab_binding_id: str (FK β†’ LabletLabBinding)                        β”‚
β”‚  β”‚   β”œβ”€β”€ session_part_id: str | None (FK β†’ external SessionPart)            β”‚
β”‚  β”‚   └── form_qualified_name: str | None (content/form reference)           β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Runtime Window ───                                                  β”‚
β”‚  β”‚   β”œβ”€β”€ started_at: datetime (lab BOOTED + binding ACTIVE)                 β”‚
β”‚  β”‚   β”œβ”€β”€ ended_at: datetime | None (lab STOPPED or binding RELEASED)        β”‚
β”‚  β”‚   └── duration_seconds: int | None (computed)                             β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Resolved Port Mapping ───                                           β”‚
β”‚  β”‚   └── allocated_ports: dict[str, PortAllocation]                         β”‚
β”‚  β”‚       # node_label β†’ {protocol, external_port, internal_port, host}      β”‚
β”‚  β”‚       # Frozen at run start for LDS/grading stability                    β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── LDS Session Integration ───                                         β”‚
β”‚  β”‚   β”œβ”€β”€ lds_session_id: str | None                                          β”‚
β”‚  β”‚   β”œβ”€β”€ lds_session_status: LdsSessionStatus | None                        β”‚
β”‚  β”‚   β”‚   # (provisioned β†’ active β†’ paused β†’ ended β†’ expired)               β”‚
β”‚  β”‚   β”œβ”€β”€ lds_login_url: str | None                                           β”‚
β”‚  β”‚   └── lds_last_event_at: datetime | None                                  β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ ─── Grading Integration ───                                             β”‚
β”‚  β”‚   β”œβ”€β”€ grading_session_id: str | None (FK β†’ GradingEngine Session)        β”‚
β”‚  β”‚   β”œβ”€β”€ grading_status: GradingStatus | None                               β”‚
β”‚  β”‚   β”‚   # (pending β†’ collecting β†’ grading β†’ reviewing β†’ submitted β†’ faulted)β”‚
β”‚  β”‚   β”œβ”€β”€ grading_score: int | None                                           β”‚
β”‚  β”‚   β”œβ”€β”€ grading_max_score: int | None                                       β”‚
β”‚  β”‚   β”œβ”€β”€ grading_submitted_at: datetime | None                               β”‚
β”‚  β”‚   └── grading_report_url: str | None (proxy URL for IFRAME)              β”‚
β”‚  β”‚                                                                           β”‚
β”‚  └── ─── Audit ───                                                           β”‚
β”‚      β”œβ”€β”€ created_by: str (user or system)                                    β”‚
β”‚      β”œβ”€β”€ status: LabletRecordRunStatus                                       β”‚
β”‚      β”‚   # (provisioning β†’ active β†’ paused β†’ ending β†’ ended β†’ faulted)      β”‚
β”‚      └── status_reason: str | None                                           β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why not just LabletLabBinding + LabRunRecord?

Concept Scope Lifecycle Purpose
LabletLabBinding Structural M:N link Bind/release "This instance uses this lab"
LabRunRecord Single start→stop cycle Start/stop "This lab ran from T1 to T2" (lab-centric)
LabletRecordRun Cross-aggregate execution context Provision→grade→end "This timeslot ran this lab for this session part, with these ports, this LDS session, and this grading result"

LabletRecordRun is the operational join β€” the single source of truth for "what happened when this candidate used this lab during this timeslot." It enriches the binding with LDS state, grading state, and resolved runtime details that neither aggregate owns alone.

class LabletRecordRunStatus(CaseInsensitiveStrEnum):
    """Lifecycle of a runtime execution mapping."""
    PROVISIONING = "provisioning"  # Lab starting, ports resolving
    ACTIVE = "active"              # Lab BOOTED, LDS provisioned, candidate can work
    PAUSED = "paused"              # LDS session paused (break, timeout)
    ENDING = "ending"              # LDS session ended, grading may be in progress
    ENDED = "ended"                # All complete β€” final state
    FAULTED = "faulted"            # Error during execution

class LdsSessionStatus(CaseInsensitiveStrEnum):
    """Status of the LDS session within a run."""
    PROVISIONED = "provisioned"    # LDS session created, not yet accessed
    ACTIVE = "active"              # Candidate logged in, session running
    PAUSED = "paused"              # Session paused (timer paused)
    ENDED = "ended"                # Session ended (by user or timer)
    EXPIRED = "expired"            # Timeslot expired, session auto-ended

class GradingStatus(CaseInsensitiveStrEnum):
    """Status of grading within a run."""
    PENDING = "pending"            # Grading not yet triggered
    COLLECTING = "collecting"      # Output collection in progress (ROC)
    GRADING = "grading"            # Rule evaluation in progress
    REVIEWING = "reviewing"        # Graded, under review
    SUBMITTED = "submitted"        # Score submitted and locked
    FAULTED = "faulted"            # Grading failed

3.5 Entity Relationship DiagramΒΆ

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       1:N        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LabletDefinition β”‚ ────────────────▢│ LabletInstance   β”‚
β”‚ (template)       β”‚                  β”‚ (workload)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚
                                         M:N  β”‚  via LabletLabBinding
                                              β”‚
                                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
                                     β”‚                   β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       0:N        β”‚   LabRecord       β”‚       1:1        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CML Worker      β”‚ ◀────────────────│   (lab lifecycle) β”‚ ────────────────▢│ RuntimeBinding    β”‚
β”‚ (compute host)  β”‚                  β”‚                   β”‚                  β”‚ (CML/K8s/Pod/BM) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚
                                         1:N  β”‚
                                              β–Ό
                                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                     β”‚  LabRunRecord      β”‚
                                     β”‚  (historical run)  β”‚
                                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

                           LabletRecordRun (cross-aggregate execution mapping)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  1:N   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  N:1   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚LabletInstance │───────▢│  LabletRecordRun     │◀───────│  LabRecord   β”‚
β”‚ (timeslot)    β”‚        β”‚                     β”‚        β”‚  (lab)        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚  β”œβ”€β”€ session_part_id β”‚        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚  β”œβ”€β”€ form_qname     β”‚
                         β”‚  β”œβ”€β”€ allocated_portsβ”‚
                         β”‚  β”œβ”€β”€ lds_session_*  │──────▢ LDS Session (ext)
                         β”‚  β”œβ”€β”€ grading_*      │──────▢ GradingEngine (ext)
                         β”‚  └── status         β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

4. LabRecord Aggregate DesignΒΆ

4.1 Value ObjectsΒΆ

RuntimeEnvironmentType (Enum)ΒΆ

class RuntimeEnvironmentType(CaseInsensitiveStrEnum):
    """Type of compute platform hosting a lab."""
    CML = "cml"           # Cisco Modeling Lab on EC2
    POD = "pod"           # Containerized lab pod
    K8S = "kubernetes"    # Kubernetes-managed lab
    BARE_METAL = "bare_metal"  # Physical lab equipment

RuntimeBinding (Value Object)ΒΆ

@dataclass(frozen=True)
class RuntimeBinding:
    """Locates a lab instance within its runtime environment.

    Abstract binding that polymorphically represents different runtime targets.
    """
    runtime_type: RuntimeEnvironmentType
    worker_id: str          # Hosting entity ID (CML worker, cluster, rack)
    runtime_lab_id: str     # Platform-specific lab identifier
    endpoint: str | None    # Access endpoint (IP, URL)

    # Runtime-specific extensions (optional, stored as dict)
    extra: dict[str, Any] = field(default_factory=dict)
    # CML: {"cml_lab_id": "abc-123", "cml_worker_ip": "10.0.0.5"}
    # K8s: {"cluster": "prod", "namespace": "labs", "pod": "lab-xyz"}
    # Pod: {"dc": "SJC", "rack": "R42", "slot": 3}

ExternalInterface (Value Object)ΒΆ

@dataclass(frozen=True)
class ExternalInterface:
    """An externally reachable interface on a lab node.

    Maps to CML node tags like ["serial:5041", "vnc:5044"].
    Used by LDS for device access provisioning.
    """
    node_label: str         # CML node label (e.g., "iosv-0")
    protocol: str           # "serial", "vnc", "ssh", "web", "telnet"
    port: int               # External port number
    host: str | None = None # Override host (defaults to worker IP)
    password: str | None = None  # Device access password (VNC)

LabTopologySpec (Value Object)ΒΆ

@dataclass(frozen=True)
class LabTopologySpec:
    """Immutable snapshot of a lab topology definition.

    Represents the YAML canvas: nodes, links, annotations, metadata.
    Each revision of a LabRecord gets a new LabTopologySpec.
    """
    version: str            # Topology schema version (e.g., "0.3.0")
    title: str
    description: str
    notes: str
    nodes: list[dict]       # Serialized node definitions
    links: list[dict]       # Serialized link definitions
    annotations: list[dict] # Canvas annotations (labels, shapes)
    metadata: dict[str, Any]  # Custom metadata
    raw_yaml: str           # Original YAML source (for re-import)

    @property
    def node_count(self) -> int:
        return len(self.nodes)

    @property
    def link_count(self) -> int:
        return len(self.links)

    def checksum(self) -> str:
        """SHA-256 of raw_yaml for diff detection."""
        import hashlib
        return hashlib.sha256(self.raw_yaml.encode()).hexdigest()

TopologySpec Detail (CML schema-aligned):

Field Source Notes
nodes[].id CML nodes[].id Stable node ID (e.g., n0)
nodes[].label CML nodes[].label Display name (e.g., iosv-0)
nodes[].node_definition CML nodes[].node_definition Node type (e.g., iosv)
nodes[].image_definition CML nodes[].image_definition Optional image override
nodes[].configuration[] CML nodes[].configuration Files (name/content)
nodes[].tags[] CML nodes[].tags Encodes protocol:port for external interfaces
nodes[].interfaces[] CML nodes[].interfaces Interface metadata (id, label, slot, type)
links[].id CML links[].id Stable link ID (e.g., l0)
links[].n1/n2 CML links[].n1/n2 Node endpoints
links[].i1/i2 CML links[].i1/i2 Interface endpoints
links[].label CML links[].label Human-readable edge label
annotations[] CML annotations Canvas metadata (text, shapes, images)
lab.title/description/notes/version CML lab.* Topology metadata

Derived Fields:

  • external_interfaces derived from node tags (serial:4567, vnc:4568)
  • node_count and link_count from nodes/links arrays

LabRevision (Value Object)ΒΆ

@dataclass(frozen=True)
class LabRevision:
    """A numbered revision of a lab topology."""
    revision: int
    topology_checksum: str       # SHA-256 of the topology YAML
    created_at: datetime
    created_by: str              # "discovery", "user:alice", "system"
    change_summary: str | None   # Human-readable changelog
    node_count: int
    link_count: int

LabRunRecord (Value Object)ΒΆ

@dataclass(frozen=True)
class LabRunRecord:
    """Historical record of a single lab execution cycle."""
    run_id: str                  # UUID
    started_at: datetime
    stopped_at: datetime | None
    duration_seconds: int | None
    started_by: str              # "lablet:abc-123", "user:admin", "system"
    stop_reason: str | None      # "timeslot_ended", "user_stop", "error"
    lablet_instance_id: str | None  # If run was for a lablet
    final_state: str             # "STOPPED", "WIPED", "ERROR"

4.2 LabRecordStatus (Enum)ΒΆ

class LabRecordStatus(CaseInsensitiveStrEnum):
    """Lifecycle states for a LabRecord.

    Independent of LabletInstance lifecycle.
    Reflects the lab's own operational state.

    State Machine:
        DISCOVERED β†’ IMPORTING β†’ DEFINED β†’ STARTING β†’ BOOTED β†’ STOPPING β†’ STOPPED
                                         β†˜ WIPING β†’ WIPED β†—
                                                           β†˜ DELETING β†’ DELETED
                                                    (from any) β†’ ERROR
                                                    (from any) β†’ ORPHANED
    """
    # Discovery & Import
    DISCOVERED = "discovered"       # Found on worker, not yet imported/tracked
    IMPORTING = "importing"         # Topology being imported to runtime
    DEFINED = "defined"             # Imported but not started (CML: DEFINED_ON_CORE)

    # Running States
    STARTING = "starting"           # Lab start initiated
    QUEUED = "queued"               # CML is queuing the lab start
    BOOTED = "booted"               # All nodes booted, lab is running
    PAUSED = "paused"               # Lab paused (future: save/restore state)

    # Shutdown States
    STOPPING = "stopping"           # Lab stop initiated
    STOPPED = "stopped"             # All nodes stopped, topology preserved
    WIPING = "wiping"               # Node configs being wiped
    WIPED = "wiped"                 # Nodes wiped, ready for fresh start

    # Cleanup States
    DELETING = "deleting"           # Lab being deleted from runtime
    DELETED = "deleted"             # Lab removed from runtime (terminal)
    ARCHIVED = "archived"          # Lab exported/saved, removed from runtime

    # Error States
    ERROR = "error"                 # Lab in error state (needs intervention)
    ORPHANED = "orphaned"           # Runtime binding lost (worker terminated)

4.3 Valid TransitionsΒΆ

LAB_RECORD_VALID_TRANSITIONS: dict[LabRecordStatus, list[LabRecordStatus]] = {
    LabRecordStatus.DISCOVERED: [IMPORTING, DEFINED, DELETED, ORPHANED],
    LabRecordStatus.IMPORTING:  [DEFINED, ERROR],
    LabRecordStatus.DEFINED:    [STARTING, WIPING, DELETING, ORPHANED, ERROR],
    LabRecordStatus.STARTING:   [QUEUED, BOOTED, ERROR],
    LabRecordStatus.QUEUED:     [BOOTED, ERROR],
    LabRecordStatus.BOOTED:     [STOPPING, PAUSED, ERROR],
    LabRecordStatus.PAUSED:     [STARTING, STOPPING, ERROR],  # Resume = re-start
    LabRecordStatus.STOPPING:   [STOPPED, ERROR],
    LabRecordStatus.STOPPED:    [STARTING, WIPING, DELETING, ARCHIVED, ORPHANED, ERROR],
    LabRecordStatus.WIPING:     [WIPED, ERROR],
    LabRecordStatus.WIPED:      [STARTING, DELETING, ARCHIVED, ORPHANED],
    LabRecordStatus.DELETING:   [DELETED, ERROR],
    LabRecordStatus.DELETED:    [],  # Terminal
    LabRecordStatus.ARCHIVED:   [],  # Terminal
    LabRecordStatus.ERROR:      [STARTING, STOPPING, WIPING, DELETING, DEFINED],  # Recovery
    LabRecordStatus.ORPHANED:   [DELETED, ARCHIVED],  # Cleanup only
}

4.4 Domain EventsΒΆ

Event Trigger Key Data
LabRecordDiscoveredDomainEvent New lab found on worker by discovery worker_id, runtime_lab_id, title, topology_snapshot
LabRecordImportedDomainEvent Lab imported from YAML definition_id, topology_spec
LabRecordStartedDomainEvent Lab start confirmed (BOOTED) runtime_binding, boot_duration
LabRecordStoppedDomainEvent Lab stopped stop_reason, run_duration
LabRecordWipedDomainEvent Lab wiped (nodes reset) β€”
LabRecordDeletedDomainEvent Lab deleted from runtime β€”
LabRecordArchivedDomainEvent Lab exported and archived archive_location
LabRecordClonedDomainEvent Lab cloned to new LabRecord source_lab_id, clone_lab_id
LabRecordRevisionCreatedDomainEvent Topology updated, new revision old_checksum, new_checksum, revision
LabRecordBoundToLabletDomainEvent Linked to a LabletInstance lablet_instance_id, role
LabRecordUnboundFromLabletDomainEvent Unlinked from a LabletInstance lablet_instance_id
LabRecordErrorDomainEvent Error occurred error_message, from_state
LabRecordOrphanedDomainEvent Worker terminated, lab unreachable worker_id
LabRecordActionRequestedDomainEvent User requests action via BFF action (start/stop/wipe/delete)
LabRecordActionCompletedDomainEvent Controller completed action action
LabRecordActionFailedDomainEvent Controller action failed action, error_message

5. Relationship Model: LabRecord ↔ LabletInstanceΒΆ

5.1 Design RationaleΒΆ

The relationship between LabRecord and LabletInstance is many-to-many with temporal semantics:

  • One LabletInstance may use multiple LabRecords β€” multi-lab topologies (e.g., a campus network + branch office as separate CML labs interconnected via OOB management).
  • One LabRecord may serve multiple LabletInstances over time β€” after one lablet's timeslot ends, the lab can be wiped and reused by the next lablet, avoiding cold-import. Only one lablet should be actively using a lab at any given time.
  • Orphan labs exist β€” labs discovered on workers that aren't associated with any lablet (admin labs, test labs, forgotten imports).

5.2 LabletLabBindingΒΆ

class BindingRole(CaseInsensitiveStrEnum):
    """Role of a LabRecord within a LabletInstance."""
    PRIMARY = "primary"       # Main lab topology
    SECONDARY = "secondary"   # Additional lab (multi-lab setup)
    AUXILIARY = "auxiliary"    # Support lab (e.g., management network)

class BindingStatus(CaseInsensitiveStrEnum):
    """Status of a lab-lablet binding."""
    ACTIVE = "active"         # Lab is currently serving this lablet
    RELEASED = "released"     # Lablet released the lab (timeslot ended)
    FAILED = "failed"         # Binding failed (lab unavailable)

@dataclass
class LabletLabBinding:
    """Join entity formalizing the LabRecord ↔ LabletInstance relationship."""
    id: str
    lablet_instance_id: str
    lab_record_id: str
    role: BindingRole
    status: BindingStatus
    bound_at: datetime
    unbound_at: datetime | None
    metadata: dict[str, Any]  # Extra context (port mappings, etc.)

5.3 Lifecycle Integration MatrixΒΆ

This matrix shows how LabletInstance and LabRecord lifecycles interact:

LabletInstance Status LabRecord Action Expected LabRecord Status Binding Status
PENDING β€” (no lab yet) (no binding)
SCHEDULED Resolve lab: reuse existing OR plan import STOPPED/WIPED or (pending import) β€”
INSTANTIATING Import lab if new; Start lab IMPORTING β†’ DEFINED β†’ STARTING β†’ BOOTED ACTIVE
READY Verify lab BOOTED, provision LDS BOOTED ACTIVE
RUNNING Sync check, maintain heartbeat BOOTED ACTIVE
COLLECTING (Lab still running for data collection) BOOTED ACTIVE
GRADING (Lab may still be running) BOOTED ACTIVE
STOPPING Stop lab if no other active bindings STOPPING β†’ STOPPED RELEASED
STOPPED Wipe lab (prepare for reuse) WIPING β†’ WIPED RELEASED
ARCHIVED (Lab preserved or deleted) STOPPED/WIPED/DELETED RELEASED
TERMINATED Force-stop if orphaned STOPPED/DELETED RELEASED

5.4 Lab Reuse StrategyΒΆ

When a new LabletInstance needs a lab identical to one that already exists on the target worker:

1. Resource Scheduler assigns LabletInstance to Worker W
2. Lablet Controller checks: does Worker W have a LabRecord
   matching the LabletDefinition topology?
   a. YES and status=WIPED β†’ Bind lablet to existing LabRecord, start lab
   b. YES and status=STOPPED β†’ Wipe first, then start
   c. NO β†’ Import fresh from LabletDefinition.topology_yaml
3. Create LabletLabBinding(role=PRIMARY, status=ACTIVE)
4. On timeslot end: Release binding, wipe lab (don't delete β†’ available for reuse)

Performance Impact:

Scenario Time Savings
Cold import + start ~90s β€”
Reuse wiped lab (start only) ~20s 78% faster
Reuse stopped lab (wipe + start) ~30s 67% faster

6. LabRecord Lifecycle State MachineΒΆ

stateDiagram-v2
    [*] --> DISCOVERED: Discovery finds lab on worker
    [*] --> IMPORTING: Import from YAML/definition

    DISCOVERED --> IMPORTING: User/system imports
    DISCOVERED --> DEFINED: Already imported in CML
    DISCOVERED --> DELETED: User deletes

    IMPORTING --> DEFINED: Import success
    IMPORTING --> ERROR: Import failed

    DEFINED --> STARTING: Start requested
    DEFINED --> WIPING: Wipe requested
    DEFINED --> DELETING: Delete requested

    STARTING --> QUEUED: CML queuing
    STARTING --> BOOTED: All nodes booted
    STARTING --> ERROR: Start failed
    QUEUED --> BOOTED: Nodes booted
    QUEUED --> ERROR: Boot failed

    BOOTED --> STOPPING: Stop requested
    BOOTED --> PAUSED: Pause requested
    BOOTED --> ERROR: Runtime error

    PAUSED --> STARTING: Resume restart
    PAUSED --> STOPPING: Stop requested

    STOPPING --> STOPPED: Stop complete
    STOPPING --> ERROR: Stop failed

    STOPPED --> STARTING: Restart
    STOPPED --> WIPING: Wipe for reuse
    STOPPED --> DELETING: Delete
    STOPPED --> ARCHIVED: Archive/export

    WIPING --> WIPED: Wipe complete
    WIPING --> ERROR: Wipe failed

    WIPED --> STARTING: Start fresh
    WIPED --> DELETING: Delete
    WIPED --> ARCHIVED: Archive

    DELETING --> DELETED: Delete complete
    DELETING --> ERROR: Delete failed

    ERROR --> STARTING: Retry start
    ERROR --> STOPPING: Force stop
    ERROR --> WIPING: Force wipe
    ERROR --> DELETING: Force delete
    ERROR --> DEFINED: Reset state

    DISCOVERED --> ORPHANED: Worker lost
    DEFINED --> ORPHANED: Worker lost
    STOPPED --> ORPHANED: Worker lost
    WIPED --> ORPHANED: Worker lost

    ORPHANED --> DELETED: Cleanup
    ORPHANED --> ARCHIVED: Preserve record

    DELETED --> [*]
    ARCHIVED --> [*]

7. Discovery & SynchronisationΒΆ

7.1 Lab Discovery (lablet-controller)ΒΆ

The existing LabsRefreshService evolves into a LabDiscoveryService that creates proper LabRecord aggregates:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     GET /api/v0/labs      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CML Worker   β”‚ ◀─────────────────────── β”‚ lablet-       β”‚
β”‚ (SPI)        β”‚ ──────────────────────▢  β”‚ controller    β”‚
β”‚              β”‚     lab list + details    β”‚              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                           β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                                   β”‚
                          POST /api/internal/       β”‚
                          lab-records/discover      β”‚
                                                   β–Ό
                                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                          β”‚ Control      β”‚
                                          β”‚ Plane API    β”‚
                                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Discovery Flow:

  1. Scan β€” For each running worker, fetch all labs from CML API
  2. Diff β€” Compare against existing LabRecords for that worker
  3. Create β€” New labs β†’ LabRecordDiscoveredDomainEvent β†’ status=DISCOVERED
  4. Update β€” Known labs β†’ sync state, detect topology changes β†’ new revision if changed
  5. Orphan β€” Labs in DB but not on CML β†’ mark ORPHANED (don't auto-delete)
  6. Emit β€” SSE events for UI real-time updates

7.2 Topology Change DetectionΒΆ

On every sync, compute SHA-256 checksum of the lab topology YAML. If changed:

  1. Create new LabRevision with incremented revision number
  2. Emit LabRecordRevisionCreatedDomainEvent
  3. Store old topology checksum for diff capability

7.3 Reconciliation (lablet-controller)ΒΆ

The existing LabletReconciler gains a lab resolution phase before instantiation:

async def _handle_instantiating(self, instance):
    # Phase 0 (NEW): Resolve lab β€” reuse or import
    lab_record = await self._resolve_lab_for_instance(instance)

    # Phase 1: If lab needs importing, import it
    if lab_record.status == LabRecordStatus.IMPORTING:
        await self._import_lab(lab_record, instance)
        return ReconciliationResult.requeue("Lab importing")

    # Phase 2: Start lab if not running
    if lab_record.status in (DEFINED, STOPPED, WIPED):
        await self._start_lab(lab_record)
        return ReconciliationResult.requeue("Lab starting")

    # Phase 3: Lab is BOOTED β€” bind + provision LDS
    if lab_record.status == LabRecordStatus.BOOTED:
        await self._bind_lab_to_instance(lab_record, instance)
        return await self._provision_lds_session(instance)

7.4 MVP Import Pipeline (CML β†’ Generic Concepts)ΒΆ

MVP must support importing CML YAML/JSON into generic runtime concepts consumed by Session and Workflow microservices.

Input: CML topology YAML/JSON (nodes, links, annotations, lab metadata)

Output: Generic artifacts

Generic Concept Source (CML) Notes
Device nodes[] Node label/type β†’ device name/type; configs β†’ device config files
Pod lab + nodes[] Pod groups devices for a SessionItem (logical lab container)
Connection links[] Link endpoints map to device interfaces
ExternalInterface nodes[].tags[] protocol:port pairs β†’ access endpoints
TopologySpec nodes/links/annotations/lab Normalized spec for LabRecord
initial_state_workflow-definition topology defaults Derived initial device states
item_transition_workflow-definition SessionItem transitions External references to SessionItem IDs
collect_and_grade_workflow-definition Assessment metadata Triggers grading pipeline
validate_score_report_workflow-definition Score schema Validation/normalization steps

Key requirement: The import must be lossless with respect to CML topology; all fields needed to reconstruct the lab in CML must be preserved in TopologySpec.raw_yaml and normalized fields.


8. Backend API DesignΒΆ

8.1 Public API (BFF β€” /api/lab-records/)ΒΆ

These endpoints are called by the frontend (Bootstrap SPA) via the BFF pattern with cookie auth.

Method Path Description Auth
GET /api/lab-records List all lab records (filterable by worker, status, owner) Cookie
GET /api/lab-records/{id} Get lab record details with topology, revisions, run history Cookie
GET /api/lab-records/{id}/topology Get current topology YAML Cookie
GET /api/lab-records/{id}/revisions Get revision history Cookie
GET /api/lab-records/{id}/runs Get run history Cookie
GET /api/lab-records/{id}/bindings Get session/lablet bindings (current and historical) Cookie
POST /api/lab-records/{id}/start Request lab start (pending action β†’ reconciliation) Cookie
POST /api/lab-records/{id}/stop Request lab stop Cookie
POST /api/lab-records/{id}/wipe Request lab wipe (reset nodes) Cookie
POST /api/lab-records/{id}/delete Request lab delete Cookie
POST /api/lab-records/{id}/clone Clone lab to new LabRecord on same/different worker Cookie
POST /api/lab-records/{id}/export Export lab topology YAML Cookie
POST /api/lab-records/{id}/archive Archive lab (export + delete) Cookie
POST /api/lab-records/{id}/bind Bind to a LabletInstance Cookie
POST /api/lab-records/{id}/unbind Unbind from a LabletInstance Cookie
POST /api/lab-records/import Import lab from YAML to a specific worker Cookie

8.2 Internal API (Controller-to-CPA β€” /api/internal/lab-records/)ΒΆ

These endpoints are called by lablet-controller using X-API-Key authentication.

Method Path Description Auth
POST /api/internal/lab-records/discover Batch create/update from discovery scan X-API-Key
POST /api/internal/lab-records/sync Legacy: bulk sync (backward compat) X-API-Key
PUT /api/internal/lab-records/{id}/status Update lab status after reconciliation X-API-Key
PUT /api/internal/lab-records/{id}/topology Update topology (new revision) X-API-Key
POST /api/internal/lab-records/{id}/run-completed Record a completed run X-API-Key
POST /api/internal/lab-records/{id}/complete-action Mark pending action as completed X-API-Key
POST /api/internal/lab-records/{id}/fail-action Mark pending action as failed X-API-Key
PUT /api/internal/lab-records/{id}/runtime-binding Update runtime binding info X-API-Key
POST /api/internal/lab-records/{id}/mark-orphaned Mark lab as orphaned (worker lost) X-API-Key

8.3 LabletInstance API ExtensionsΒΆ

Method Path Description
GET /api/lablet-instances/{id}/labs Get all LabRecords bound to this instance
POST /api/lablet-instances/{id}/labs/bind Bind a LabRecord to this instance
DELETE /api/lablet-instances/{id}/labs/{lab_id}/unbind Unbind a LabRecord

8.4 Worker API ExtensionsΒΆ

Method Path Description
GET /api/workers/{id}/labs Get all LabRecords on this worker
POST /api/workers/{id}/labs/discover Trigger immediate lab discovery for this worker
GET /api/workers/{id}/labs/stats Lab count/status summary for this worker

8.5 CQRS Commands & QueriesΒΆ

Commands (self-contained: request + handler in same file)ΒΆ

File Command Handler
discover_lab_records_command.py DiscoverLabRecordsCommand Creates/updates LabRecords from discovery scan
import_lab_record_command.py ImportLabRecordCommand Creates LabRecord from YAML import
start_lab_record_command.py StartLabRecordCommand Sets pending_action=start
stop_lab_record_command.py StopLabRecordCommand Sets pending_action=stop
wipe_lab_record_command.py WipeLabRecordCommand Sets pending_action=wipe
delete_lab_record_command.py DeleteLabRecordCommand Sets pending_action=delete
clone_lab_record_command.py CloneLabRecordCommand Creates new LabRecord from existing
archive_lab_record_command.py ArchiveLabRecordCommand Exports and marks archived
bind_lab_to_lablet_command.py BindLabToLabletCommand Creates LabletLabBinding
unbind_lab_from_lablet_command.py UnbindLabFromLabletCommand Releases binding
update_lab_record_status_command.py UpdateLabRecordStatusCommand Internal: controller status updates
complete_lab_action_command.py CompleteLabActionCommand Internal: mark action completed
fail_lab_action_command.py FailLabActionCommand Internal: mark action failed
update_lab_topology_command.py UpdateLabTopologyCommand Internal: new revision on topology change
record_lab_run_command.py RecordLabRunCommand Internal: record run completion
sync_lab_records_command.py (existing) Legacy sync β€” delegates to discover

QueriesΒΆ

File Query Handler
get_lab_records_query.py GetLabRecordsQuery List with filters (worker, status, owner, bound/unbound)
get_lab_record_query.py GetLabRecordQuery Single lab record with full details
get_lab_record_topology_query.py GetLabRecordTopologyQuery Current topology YAML
get_lab_record_revisions_query.py GetLabRecordRevisionsQuery Revision history
get_lab_record_runs_query.py GetLabRecordRunsQuery Run history
get_lab_record_bindings_query.py GetLabRecordBindingsQuery Session/lablet bindings
get_worker_labs_query.py GetWorkerLabsQuery All labs on a worker
get_lablet_labs_query.py GetLabletLabsQuery All labs bound to a lablet

8.6 SSE EventsΒΆ

Event Type Payload Trigger
lab.discovered {lab_record_id, worker_id, title, status} Discovery finds new lab
lab.status.updated {lab_record_id, old_status, new_status} Any status transition
lab.topology.updated {lab_record_id, revision, node_count, link_count} Topology revision
lab.bound {lab_record_id, lablet_instance_id, role} Lab bound to lablet
lab.unbound {lab_record_id, lablet_instance_id} Lab unbound from lablet
lab.action.requested {lab_record_id, action} User requested action
lab.action.completed {lab_record_id, action} Controller completed action
lab.action.failed {lab_record_id, action, error} Controller action failed
lab.run.completed {lab_record_id, run_id, duration} Run cycle completed
worker.labs.synced {worker_id, synced, created, updated, orphaned} Discovery scan complete

8.7 LabletRecordRun API (BFF β€” /api/lablet-record-runs/)ΒΆ

The LabletRecordRun represents the runtime execution join between a LabletInstance and a LabRecord. These endpoints manage the run lifecycle including LDS provisioning and grading.

Method Path Description Auth
GET /api/lablet-record-runs List runs (filter by instance, lab, session_part, status) Cookie
GET /api/lablet-record-runs/{id} Get run details (ports, LDS state, grading state) Cookie
POST /api/lablet-record-runs Create a run (binds instance+lab+session_part at runtime) Cookie
POST /api/lablet-record-runs/{id}/end End a run (triggers cleanup sequence) Cookie

8.8 LDS Session API (BFF β€” via LabletRecordRun)ΒΆ

LDS operations are scoped to a LabletRecordRun β€” each run has at most one LDS session.

Method Path Description Auth
POST /api/lablet-record-runs/{id}/lds/provision Provision LDS session (form_qname + ports β†’ lds_session_id + login_url) Cookie
POST /api/lablet-record-runs/{id}/lds/start Start/activate the LDS session Cookie
POST /api/lablet-record-runs/{id}/lds/pause Pause the LDS session (freeze timer) Cookie
POST /api/lablet-record-runs/{id}/lds/resume Resume a paused LDS session Cookie
POST /api/lablet-record-runs/{id}/lds/end End the LDS session Cookie
GET /api/lablet-record-runs/{id}/lds/status Get current LDS session status Cookie

8.9 Grading API (BFF β€” via LabletRecordRun)ΒΆ

Grading operations are scoped to a LabletRecordRun. The LCM BFF proxies requests to the GradingEngine, translating the LabletRecordRun context into the GradingEngine's Session/SessionPart model.

Method Path Description Auth
POST /api/lablet-record-runs/{id}/grade Trigger grading (collect + evaluate) Cookie
GET /api/lablet-record-runs/{id}/grade/report/summary Get inline score summary (JSON) Cookie
GET /api/lablet-record-runs/{id}/grade/report Get full report URL (for IFRAME) Cookie
POST /api/lablet-record-runs/{id}/grade/submit Submit/lock final score Cookie
POST /api/lablet-record-runs/{id}/grade/reread Request re-grading (unlock score) Cookie

8.10 LabletRecordRun CQRS Commands & QueriesΒΆ

CommandsΒΆ

File Command Handler
create_lablet_record_run_command.py CreateLabletRecordRunCommand Creates run, resolves port mapping, sets status=PROVISIONING
provision_lds_session_command.py ProvisionLdsSessionCommand Calls LDS adapter, stores lds_session_id + login_url
start_lds_session_command.py StartLdsSessionCommand Activates LDS session
pause_lds_session_command.py PauseLdsSessionCommand Pauses LDS session timer
resume_lds_session_command.py ResumeLdsSessionCommand Resumes LDS session timer
end_lds_session_command.py EndLdsSessionCommand Ends LDS session, updates run status
trigger_grading_command.py TriggerGradingCommand Calls GradingEngine API, updates grading_status
submit_grade_command.py SubmitGradeCommand Locks final score in GradingEngine
request_reread_command.py RequestRereadCommand Unlocks score for re-evaluation
end_lablet_record_run_command.py EndLabletRecordRunCommand Ends run, cleanup sequence
update_lablet_record_run_status_command.py UpdateLabletRecordRunStatusCommand Internal: event-driven status updates

QueriesΒΆ

File Query Handler
get_lablet_record_runs_query.py GetLabletRecordRunsQuery List with filters
get_lablet_record_run_query.py GetLabletRecordRunQuery Single run with full details
get_run_grading_report_query.py GetRunGradingReportQuery Proxies to GradingEngine for report
get_run_lds_status_query.py GetRunLdsStatusQuery Current LDS session status

8.11 Extended SSE Events (LabletRecordRun, LDS, Grading)ΒΆ

Event Type Payload Trigger
run.created {run_id, lablet_instance_id, lab_record_id, session_part_id, status} Run created
run.status.updated {run_id, old_status, new_status} Any run status transition
run.lds.provisioned {run_id, lds_session_id, lds_login_url} LDS session provisioned
run.lds.active {run_id, lds_session_id} LDS session started/resumed
run.lds.paused {run_id, lds_session_id} LDS session paused
run.lds.ended {run_id, lds_session_id, reason} LDS session ended
run.grading.started {run_id, grading_session_id} Grading triggered
run.grading.collecting {run_id, progress} ROC collecting device outputs
run.grading.completed {run_id, score, max_score, generation} Grading complete
run.grading.faulted {run_id, error} Grading failed
run.grading.submitted {run_id, final_score} Score submitted/locked
run.grading.reread {run_id} Score unlocked for re-evaluation

9. Frontend DesignΒΆ

The frontend follows the established LCM stack: Bootstrap 5 SPA with Web Components extending BaseComponent (from @neuroglia/ui-core), EventBus singleton for pub/sub, StateStore with slices for state management, SSEClient for real-time updates, and Parcel for bundling. All pages use the existing Light DOM + template literal rendering pattern.

9.1 Session-Centric Navigation & Information ArchitectureΒΆ

The UI should treat Session as the primary navigation concept, with LabletInstances, LabRecords, and Grading as contextual detail within Sessions. Labs also have an independent management page for admin operations.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ☰ Overview β”‚ πŸ‘· Workers β”‚ πŸ§ͺ Labs β”‚ πŸŽ›οΈ Sessions β”‚ πŸ“… Schedule β”‚ βš™οΈ Systemβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
        β–Ό                               β–Ό
   Sessions List Page             Session Detail Page
   (all sessions, filterable)     (single session context)
        β”‚                               β”‚
        β”‚                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                     β–Ό         β–Ό                 β–Ό
        β”‚              SessionPart   SessionPart     Metadata
        β”‚              (Tab/Accordion)               (timeslot, location)
        β”‚                     β”‚
        β”‚            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚            β–Ό                 β–Ό
        β”‚     LabletInstance      LabletRecordRun
        β”‚     (lifecycle card)   (runtime execution)
        β”‚            β”‚                 β”‚
        β”‚       β”Œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚       β–Ό         β–Ό       β–Ό              β–Ό            β–Ό
        β”‚   LabRecord  Worker   LDS Session    Grading     CML Dashboard
        β”‚   (detail)   (link)   (IFRAME)     (IFRAME/panel) (IFRAME)
        β”‚
        β–Ό
   Labs Management Page
   (standalone admin view of all LabRecords across workers)

9.2 Sessions Page (/sessions) β€” Primary Experience ViewΒΆ

Replaces the current "Lablets" page concept. Shows all Sessions with their lifecycle state, combining data from the session-manager (consumed via API/events) and LCM's own LabletInstance/LabRecord domain.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  πŸŽ›οΈ Sessions                                        [+ New Session β–Ύ]     β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ Filter: [All States β–Ύ] [All Locations β–Ύ] [Date Range πŸ“…]             β”‚ β”‚
β”‚  β”‚         [πŸ” Search by candidate / session ID...]                      β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Session          β”‚ Candidate β”‚ Location β”‚ Timeslot       β”‚ Status   β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚ CCIE-ENT-2026-01 β”‚ J. Smith  β”‚ SJC-2    β”‚ Feb 10 08-16h  β”‚ 🟒 ACTIVβ”‚  β”‚
β”‚  β”‚ CCIE-SEC-2026-02 β”‚ A. Jones  β”‚ RTP-1    β”‚ Feb 10 09-17h  β”‚ 🟑 PROV β”‚  β”‚
β”‚  β”‚ CCNP-LAB-2026-03 β”‚ B. Chen   β”‚ BGL-3    β”‚ Feb 11 10-14h  β”‚ βšͺ SCHEDβ”‚  β”‚
β”‚  β”‚ CCIE-DC-2026-04  β”‚ M. Patel  β”‚ SJC-2    β”‚ Feb 09 08-16h  β”‚ βœ… ENDEDβ”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  πŸ“Š Today: 12 Active β”‚ 3 Provisioning β”‚ 8 Scheduled β”‚ 5 Ended             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data sources:

  • Session metadata (candidate, location, timeslot, exam track) β€” from session-manager via SPI or cached read model
  • LabletInstance status, worker assignment β€” from LCM domain
  • LDS session status β€” from LabletRecordRun.lds_session_status

9.3 Session Detail Page β€” Master Detail LayoutΒΆ

Clicking a session row opens the Session Detail Page, which is the central operational view. It contains tabs/accordions for each SessionPart, with nested LabletInstance and LabletRecordRun details.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ← Sessions  β”‚  πŸŽ›οΈ Session: CCIE-ENT-2026-01                   Γ—          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                             β”‚
β”‚  Candidate:  John Smith (cisco_id: JSMITH01)                               β”‚
β”‚  Track:      CCIE Enterprise Infrastructure v1.1                           β”‚
β”‚  Location:   SJC-2  β”‚  Timeslot: Feb 10, 08:00 – 16:00 PST               β”‚
β”‚  Status:     🟒 Active  β”‚  LDS: βœ… Active  β”‚  Grading: ⏳ Pending        β”‚
β”‚                                                                             β”‚
β”œβ”€β”€ Session Parts ─────────────────────────────────────────────────────────────
β”‚                                                                             β”‚
β”‚  β”Œβ”€ Part 1: CCIE-ENT-DES-1.1 ──────────────────────────── [β–Ό Expand] ──┐  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  Form: Exam CCIE Enterprise DES 1.1                                   β”‚  β”‚
β”‚  β”‚  Status: 🟒 Active  β”‚  Score: 78/100 (grading: reviewing)            β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€ LabletInstance: inst-abc-123 ─────────────────────────────────┐   β”‚  β”‚
β”‚  β”‚  β”‚  Definition: CCIE-ENT-DES-1.1-topology                        β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  Worker: worker-i-01a2b3c (10.0.1.5)                          β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  Status: 🟒 RUNNING  β”‚  Lab: 🟒 BOOTED (TEST-LAB-DES-1.1)   β”‚   β”‚  β”‚
β”‚  β”‚  β”‚                                                                β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  [β–Ά Start Lab] [⏸ Pause LDS] [πŸ”„ Wipe Lab] [πŸ“Š Grade]       β”‚   β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€ Runtime Details (LabletRecordRun) ────────────────────────────┐   β”‚  β”‚
β”‚  β”‚  β”‚  Run ID: run-xyz-789                                           β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  Started: Feb 10 08:15  β”‚  Duration: 2h 45m (running)         β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  LDS Session: lds-session-456  β”‚  Status: 🟒 Active           β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  Grading Session: gs-789  β”‚  Status: ⏳ Pending                β”‚   β”‚  β”‚
β”‚  β”‚  β”‚                                                                β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  Port Mapping:                                                 β”‚   β”‚  β”‚
β”‚  β”‚  β”‚   iosv-0:  serial β†’ :5041  β”‚  vnc β†’ :5044                    β”‚   β”‚  β”‚
β”‚  β”‚  β”‚   ubuntu:  web β†’ :5045     β”‚  ssh β†’ :5046                    β”‚   β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€ πŸ–₯️ Lab Session ──────────────────────── [β†— Open in Tab] ──── ┐   β”‚  β”‚
β”‚  β”‚  β”‚  ╔════════════════════════════════════════════════════════════╗ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         LDS Session IFRAME                               β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         (candidate lab experience)                       β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘  src="{lds_login_url}" (auto-mapped port endpoints)     β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• β”‚   β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€ πŸ“Š Score Report ──────────────────────── [β†— Open in Tab] ──── ┐  β”‚  β”‚
β”‚  β”‚  β”‚  ╔════════════════════════════════════════════════════════════╗ β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         Grading Engine Score Report IFRAME               β•‘ β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         (or inline score panel if simple)                β•‘ β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€ πŸ”§ CML Dashboard ────────────────────── [β†— Open in Tab] ──── ┐   β”‚  β”‚
β”‚  β”‚  β”‚  ╔════════════════════════════════════════════════════════════╗ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         CML Worker Dashboard IFRAME                      β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘         (admin lab view: topology, node status, console) β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘  src="https://{worker_ip}/lab/{cml_lab_id}"              β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•‘                                                          β•‘ β”‚   β”‚  β”‚
β”‚  β”‚  β”‚  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• β”‚   β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚  β”‚                                                                       β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€ Part 2: CCIE-ENT-IMPL-1.1 ─────────────────────── [β–Ά Expand] ──────┐  β”‚
β”‚  β”‚  Form: Exam CCIE Enterprise IMPL 1.1  β”‚  Status: βšͺ Pending         β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”œβ”€β”€ Timeline ──────────────────────────────────────────────────────────────────
β”‚  08:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16:00  β”‚
β”‚  β–β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘        β”‚
β”‚  Part 1 (08:00-10:45)  β–β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ                    β”‚
β”‚                          Part 2 (10:45-16:00)                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9.4 Labs Management Page (/labs)ΒΆ

Independent admin page for managing all LabRecords across workers β€” unchanged from Β§9.1 in previous version. This page exists alongside Sessions for operational management of lab assets.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  πŸ§ͺ Lab Records                                         [Import Lab β–Ύ]    β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Filter: [All Workers β–Ύ] [All States β–Ύ] [Bound/Unbound β–Ύ]            β”‚  β”‚
β”‚  β”‚         [πŸ” Search by title...]                                      β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Title        β”‚ Worker    β”‚ Status  β”‚ Nodes β”‚ Links β”‚ Active Run    β”‚  β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚
β”‚  β”‚ TEST-LAB-1.1 β”‚ worker-01 β”‚ 🟒 BOOT β”‚  3    β”‚  2    β”‚ run-abc (act) β”‚  β”‚
β”‚  β”‚ CCNA-Base    β”‚ worker-01 β”‚ βšͺ WIPEDβ”‚  5    β”‚  4    β”‚ β€”             β”‚  β”‚
β”‚  β”‚ SD-WAN-Lab   β”‚ worker-02 β”‚ πŸ”΅ STOP β”‚  12   β”‚  15   β”‚ β€”             β”‚  β”‚
β”‚  β”‚ ENCOR-v2     β”‚ worker-02 β”‚ 🟒 BOOT β”‚  8    β”‚  7    β”‚ run-xyz (act) β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  Labs: 42 total β”‚ 12 booted β”‚ 8 stopped β”‚ 15 wiped β”‚ 7 other              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Lab Detail Modal (accessible from Labs page or from Session Detail):

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ℹ️ Lab Record: TEST-LAB-1.1                                    Γ—          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  πŸ“‹ Overview  β”‚  πŸ—ΊοΈ Topology  β”‚  πŸ“œ Revisions  β”‚  πŸ”— Bindings/Runs  β”‚   β”‚
β”‚               β”‚               β”‚  πŸ“Š Run History β”‚  ⚑ Events          β”‚   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                             β”‚
β”‚  ID:       abc-123-def-456                                                 β”‚
β”‚  Worker:   worker-i-019159ed0b8bbfb33                                      β”‚
β”‚  Status:   🟒 BOOTED                                                       β”‚
β”‚  Runtime:  CML (lab_id: 7a4b2c)                                            β”‚
β”‚  Revision: #3 (updated 2h ago)                                             β”‚
β”‚  Source:   discovery                                                        β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€ Active LabletRecordRun ────────────────────────────────────────────┐   β”‚
β”‚  β”‚  Run: run-abc-789  β”‚  Instance: inst-A  β”‚  Session: CCIE-ENT-01    β”‚   β”‚
β”‚  β”‚  Started: 08:15  β”‚  LDS: 🟒 Active  β”‚  Grading: ⏳ Pending        β”‚   β”‚
β”‚  β”‚  [View Session Detail β†’]                                            β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€ Topology Summary ──────────────────────────────────────────────────┐   β”‚
β”‚  β”‚  Nodes: 3  β”‚  Links: 2  β”‚  Interfaces: serial(2), vnc(1)           β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  [🟒 Start] [πŸ”΄ Stop] [πŸ”„ Wipe] [πŸ“‹ Clone] [πŸ’Ύ Export]       [Close]    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9.5 LDS Session Integration (IFRAME)ΒΆ

The LDS lab experience is embedded directly in the Session Detail page via an IFRAME, following the established LcmGrafanaPanel pattern (loading states, error handling, theme sync, retry).

9.5.1 IFRAME ArchitectureΒΆ

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LCM Frontend (parent window)                                               β”‚
β”‚                                                                              β”‚
β”‚  LcmLdsSessionPanel (Web Component extends BaseComponent)                   β”‚
β”‚  β”œβ”€β”€ Props: runId, ldsSessionId, ldsLoginUrl                                β”‚
β”‚  β”œβ”€β”€ State: loading | ready | error | ended                                 β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  β”‚  IFRAME (sandbox="allow-scripts allow-same-origin allow-forms") β”‚    β”‚
β”‚  β”‚  β”‚  src="{lds_login_url}"                                          β”‚    β”‚
β”‚  β”‚  β”‚                                                                  β”‚    β”‚
β”‚  β”‚  β”‚  β”Œβ”€ LDS Application ─────────────────────────────────────────┐  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Auto-login with lab_password + port mappings              β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Device consoles via allocated ports (serial, vnc, web)    β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Content sections from form_qualified_name                 β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Timer tracking (timeslot_start β†’ timeslot_end)            β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚    β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ postMessage API (bidirectional):                                        β”‚
β”‚  β”‚   Parent β†’ LDS:  { type: "lcm:pause" | "lcm:resume" | "lcm:end" }      β”‚
β”‚  β”‚   LDS β†’ Parent:  { type: "lds:status", status: "active|paused|ended" }  β”‚
β”‚  β”‚   LDS β†’ Parent:  { type: "lds:grade_request", part_id: "..." }          β”‚
β”‚  β”‚   LDS β†’ Parent:  { type: "lds:timer_update", remaining_seconds: N }     β”‚
β”‚  β”‚                                                                           β”‚
β”‚  └── Actions:                                                                β”‚
β”‚      [β†— Open in Tab]  [⏸ Pause]  [β–Ά Resume]  [πŸ›‘ End Session]             β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9.5.2 LDS Session Lifecycle (from LCM perspective)ΒΆ

1. LabletRecordRun created (lab BOOTED, binding ACTIVE)
       β”‚
       β–Ό
2. LCM provisions LDS Session:
   POST /api/lablet-record-runs/{runId}/lds/provision
       β”‚  Body: { form_qualified_name, candidate_id, allocated_ports }
       β–Ό
3. LDS returns session_id + login_url
   LabletRecordRun.lds_session_status = PROVISIONED
       β”‚
       β–Ό
4. UI renders IFRAME with lds_login_url
   Candidate accesses lab environment
   LDS posts status events β†’ LCM listens
   LabletRecordRun.lds_session_status = ACTIVE
       β”‚
       β”œβ”€β”€ [Pause] β†’ postMessage("lcm:pause") β†’ LDS pauses timer
       β”‚              LabletRecordRun.lds_session_status = PAUSED
       β”‚
       β”œβ”€β”€ [Resume] β†’ postMessage("lcm:resume") β†’ LDS resumes
       β”‚               LabletRecordRun.lds_session_status = ACTIVE
       β”‚
       β–Ό
5. Session ends (user clicks End, timer expires, or admin ends):
   postMessage("lcm:end") or LDS auto-ends
   LabletRecordRun.lds_session_status = ENDED
       β”‚
       β–Ό
6. Post-session: Lab may remain BOOTED for grading output collection

9.5.3 Port Mapping ResolutionΒΆ

When LDS provisions a session, it needs the device access endpoints (hostname:port) for each device in the lab. These come from the LabletRecordRun.allocated_ports which are resolved from:

  1. LabRecord.external_interfaces β€” parsed from CML node tags (serial:5041)
  2. CML Worker IP β€” the EC2 instance's reachable IP address
  3. LabletInstance.allocated_ports β€” historically maintained port mapping
# Resolved port mapping frozen at run start
allocated_ports = {
    "iosv-0": {
        "serial": {"host": "10.0.1.5", "port": 5041, "protocol": "telnet"},
        "vnc":    {"host": "10.0.1.5", "port": 5044, "protocol": "vnc"},
    },
    "ubuntu-desktop": {
        "web":    {"host": "10.0.1.5", "port": 5045, "protocol": "https"},
        "ssh":    {"host": "10.0.1.5", "port": 5046, "protocol": "ssh"},
    },
    "vmanage-mock": {
        "web":    {"host": "10.0.1.5", "port": 5047, "protocol": "https"},
    }
}

These map directly to the GradingEngine's Pod.Devices[].Interfaces[] structure (see Appendix D.6).

9.5.4 CML Dashboard IFRAMEΒΆ

In addition to the LDS Session IFRAME (candidate-facing), the Session Detail page embeds a CML Worker Dashboard IFRAME β€” this provides the admin/proctor view of the underlying CML lab. It follows the same LcmGrafanaPanel pattern (loading, error, theme sync, retry).

Purpose: Operators and proctors need direct visibility into the CML lab topology, node statuses, console access, and resource utilisation without leaving the Session context. This is especially useful for:

  • Troubleshooting β€” diagnosing node boot failures, interface issues, or resource exhaustion
  • Proctoring β€” monitoring candidate activity at the network layer
  • Manual intervention β€” accessing node consoles when automated remediation isn't sufficient

Component: LcmCmlDashboardPanel

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LcmCmlDashboardPanel (Web Component extends BaseComponent)                 β”‚
β”‚  β”œβ”€β”€ Props: runId, workerId, workerIp, cmlLabId                             β”‚
β”‚  β”œβ”€β”€ State: loading | ready | error | unavailable                           β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  β”‚  IFRAME (sandbox="allow-scripts allow-same-origin")             β”‚    β”‚
β”‚  β”‚  β”‚  src="https://{worker_ip}/lab/{cml_lab_id}"                     β”‚    β”‚
β”‚  β”‚  β”‚                                                                  β”‚    β”‚
β”‚  β”‚  β”‚  β”Œβ”€ CML Dashboard ───────────────────────────────────────────┐  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Topology canvas (nodes, links, status indicators)        β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Node console access (serial, VNC)                        β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Resource gauges (CPU, memory per node)                   β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β”‚  Lab lifecycle controls (start/stop/wipe β€” if permitted)  β”‚  β”‚    β”‚
β”‚  β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚    β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚  β”‚                                                                           β”‚
β”‚  β”œβ”€β”€ Auth: CML admin credentials injected via URL params or session cookie  β”‚
β”‚  β”œβ”€β”€ Visibility: Only shown when LabletRecordRun is ACTIVE or PAUSED        β”‚
β”‚  β”‚               (lab must be BOOTED on the worker)                          β”‚
β”‚  β”‚               Hidden after run ENDED (lab may be wiped)                   β”‚
β”‚  β”‚                                                                           β”‚
β”‚  └── Actions:                                                                β”‚
β”‚      [β†— Open in Tab]  [πŸ”„ Refresh]                                          β”‚
β”‚                                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key differences from LDS IFRAME:

Aspect LDS Session IFRAME CML Dashboard IFRAME
Audience Candidate (end-user) Operator / Proctor (admin)
Source URL lds_login_url (from LDS service) https://{worker_ip}/lab/{cml_lab_id} (CML native)
Auth model Lab password + port mapping CML admin credentials (injected)
postMessage Bidirectional (pause/resume/grade) None (read-only observation)
Visibility ACTIVE, PAUSED states ACTIVE, PAUSED states (while lab BOOTED)
Controls Pause, Resume, End Session Open in Tab, Refresh

9.6 Grading IntegrationΒΆ

9.6.1 Grading TriggersΒΆ

Grading can be triggered in three ways, all resulting in the same backend flow:

Trigger Source Mechanism
On-demand User clicks "πŸ“Š Grade" button in Session Detail POST /api/lablet-record-runs/{runId}/grade
LDS event LDS posts lds:grade_request via postMessage EventBus β†’ API call (same endpoint)
Auto-trigger LDS session ends β†’ auto-grade if configured SSE event handler in sseAdapter.js
Grade Request β†’ LCM API
    β”‚
    β–Ό
POST /api/lablet-record-runs/{runId}/grade
    β”‚  (Command: TriggerGradingCommand)
    β–Ό
LCM resolves: Session + SessionPart + Pod (from LabletRecordRun)
    β”‚
    β–Ό
LCM calls GradingEngine API:
    POST /grading-engine/sessions/{gradingSessionId}/parts/{partId}/grade
    β”‚  Body: { pod: { id, devices: [...] }, recollect: true }
    β”‚  (Pod.Devices populated from LabletRecordRun.allocated_ports)
    β–Ό
GradingEngine:
    1. ROC collects outputs from devices (LDS ROC + IOS ROC)
    2. Evaluates GradingRuleset (from grade.xml)
    3. Produces SessionPartScoreReport
    4. Emits CloudEvent: grading.completed / grading.faulted
    β”‚
    β–Ό
LCM receives CloudEvent β†’ Updates LabletRecordRun:
    grading_status = REVIEWING | FAULTED
    grading_score = 78
    grading_max_score = 100
    β”‚
    β–Ό
SSE β†’ UI updates Score Report panel

9.6.2 Score Report DisplayΒΆ

The score report can be displayed in two modes:

Mode A: IFRAME (full GradingEngine UI):

β”Œβ”€ πŸ“Š Score Report ───────────────────────────── [β†— Open in Tab] ────────┐
β”‚  ╔══════════════════════════════════════════════════════════════════════╗│
β”‚  β•‘  GradingEngine Score Report UI (IFRAME)                            β•‘β”‚
β”‚  β•‘  src="/api/lablet-record-runs/{runId}/grade/report"                β•‘β”‚
β”‚  β•‘                                                                    β•‘β”‚
β”‚  β•‘  β€’ Section-by-section breakdown                                    β•‘β”‚
β”‚  β•‘  β€’ Item-level pass/fail indicators                                 β•‘β”‚
β”‚  β•‘  β€’ Collected outputs with rule match results                       β•‘β”‚
β”‚  β•‘  β€’ Edit capabilities for rereads                                   β•‘β”‚
β”‚  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β”‚
β”‚                                                                         β”‚
β”‚  [πŸ”„ Re-grade] [✏️ Reread] [βœ… Submit Score] [πŸ“₯ Export PDF]          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Mode B: Inline Summary Panel (lightweight):

β”Œβ”€ πŸ“Š Score Report ─────────────────────────────────────────────────────┐
β”‚                                                                        β”‚
β”‚  Overall: 78/100 (78%)  β”‚  Cut Score: 80  β”‚  Status: ⚠️ REVIEWING    β”‚
β”‚                                                                        β”‚
β”‚  β”Œβ”€ Section Scores ───────────────────────────────────────────────┐   β”‚
β”‚  β”‚  Β§ Content Understanding .............. 22/25 (88%) βœ…        β”‚   β”‚
β”‚  β”‚  Β§ Network Configuration .............. 18/25 (72%) ⚠️        β”‚   β”‚
β”‚  β”‚  Β§ Troubleshooting .................... 20/25 (80%) βœ…        β”‚   β”‚
β”‚  β”‚  Β§ Automation & Scripting ............. 18/25 (72%) ⚠️        β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                        β”‚
β”‚  Generation: 2  β”‚  Revision: 1  β”‚  Ruleset: LAB-1.1-v3               β”‚
β”‚  Last Graded: Feb 10 10:42  β”‚  Duration: 45s                         β”‚
β”‚                                                                        β”‚
β”‚  [πŸ”„ Re-grade] [✏️ Reread] [βœ… Submit] [πŸ“Š Full Report β†’]           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The UI renders Mode B by default (inline summary from LabletRecordRun.grading_* fields + a GET /api/lablet-record-runs/{runId}/grade/report/summary call). The "Full Report β†’" button opens Mode A (IFRAME or new tab).

9.6.3 Grading Lifecycle EventsΒΆ

The UI subscribes to grading events via SSE for real-time updates:

SSE Event UI Action
run.grading.started Show spinner on Grade button, update status badge
run.grading.collecting Show "Collecting outputs..." progress
run.grading.completed Refresh score panel, show toast notification
run.grading.faulted Show error alert with retry button
run.grading.submitted Lock score panel, update status to "Submitted"
run.grading.reread Unlock score panel, reset status to "Reviewing"

9.7 LabletRecordRun Lifecycle in the UIΒΆ

The LabletRecordRun status drives the UI state of the Session Detail page:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LabletRecordRun Status β”‚ UI State                                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ PROVISIONING            β”‚ Spinner + "Preparing lab environment..."   β”‚
β”‚                         β”‚ Lab status badge, worker assignment shown  β”‚
β”‚                         β”‚ No IFRAME yet                              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ ACTIVE                  β”‚ Full UI: LDS IFRAME visible               β”‚
β”‚                         β”‚ CML Dashboard IFRAME visible (admin)      β”‚
β”‚                         β”‚ Action bar: [Pause] [Grade] [End]         β”‚
β”‚                         β”‚ Port mapping table visible                 β”‚
β”‚                         β”‚ Timer countdown (if timeslot-bounded)     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ PAUSED                  β”‚ LDS IFRAME dimmed with "Paused" overlay   β”‚
β”‚                         β”‚ CML Dashboard IFRAME still visible        β”‚
β”‚                         β”‚ Action bar: [Resume] [Grade] [End]        β”‚
β”‚                         β”‚ Timer paused                               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ ENDING                  β”‚ IFRAME hidden or read-only                 β”‚
β”‚                         β”‚ "Session ending..." status                β”‚
β”‚                         β”‚ Grade button active (final grade)          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ ENDED                   β”‚ No IFRAME                                  β”‚
β”‚                         β”‚ Score report panel (Mode B or Mode A)     β”‚
β”‚                         β”‚ Run summary with duration                  β”‚
β”‚                         β”‚ Action bar: [Re-grade] [Reread] [Submit]  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ FAULTED                 β”‚ Error alert with details                   β”‚
β”‚                         β”‚ [Retry] [Force End] buttons               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9.8 New Web ComponentsΒΆ

Following the established BaseComponent β†’ custom element pattern:

Component Tag Responsibility
SessionsPage <sessions-page> Top-level sessions list with filters, stats, SSE
SessionDetailPage <session-detail-page> Single session view with SessionParts
SessionPartPanel <session-part-panel> Expandable part with LabletInstance + LabletRecordRun
LabletRecordRunCard <lablet-record-run-card> Runtime details: ports, LDS status, grading status
LcmLdsSessionPanel <lcm-lds-session-panel> LDS IFRAME wrapper (mirrors LcmGrafanaPanel pattern)
LcmGradingPanel <lcm-grading-panel> Score report display (inline summary + IFRAME mode)
LcmCmlDashboardPanel <lcm-cml-dashboard-panel> CML Worker Dashboard IFRAME (admin topology/console view)
LabsPage <labs-page> Standalone lab records management page
LabDetailModal <lab-detail-modal> Lab record detail modal with tabs
PortMappingTable <port-mapping-table> Device port allocation display
RunTimeline <run-timeline> Visual timeline of session parts and runs

Component hierarchy:

<sessions-page>
  └─ <session-detail-page session-id="...">
       β”œβ”€ Session metadata header
       β”œβ”€ <session-part-panel part-id="..." form-qname="...">  (per part)
       β”‚    β”œβ”€ <lablet-record-run-card run-id="...">
       β”‚    β”‚    └─ <port-mapping-table>
       β”‚    β”œβ”€ <lcm-lds-session-panel run-id="..." lds-url="...">
       β”‚    β”œβ”€ <lcm-grading-panel run-id="..." grading-session-id="...">
       β”‚    └─ <lcm-cml-dashboard-panel run-id="..." worker-ip="..." cml-lab-id="...">
       └─ <run-timeline session-id="...">

9.9 State Management ExtensionsΒΆ

New StateStore SlicesΒΆ

// slices/sessionsSlice.js
export const sessionsSlice = {
    name: 'sessions',
    initialState: {
        byId: {},           // Session read models (from session-manager SPI)
        allIds: [],
        selectedId: null,   // Currently viewed session
        loading: false,
        filters: { status: null, location: null, dateRange: null },
    },
    reducers: {
        setSessions: (state, sessions) => { /* bulk set */ },
        upsertSession: (state, session) => { /* single upsert */ },
        selectSession: (state, sessionId) => { /* set selected */ },
        setFilters: (state, filters) => { /* update filters */ },
    },
};

// slices/runsSlice.js
export const runsSlice = {
    name: 'runs',
    initialState: {
        byId: {},           // LabletRecordRun entities
        bySessionPartId: {},// Index: sessionPartId β†’ [runId, ...]
        byLabRecordId: {},  // Index: labRecordId β†’ [runId, ...]
        loading: false,
    },
    reducers: {
        upsertRun: (state, run) => { /* single upsert */ },
        updateRunLds: (state, { runId, ldsStatus, ldsSessionId }) => { /* LDS update */ },
        updateRunGrading: (state, { runId, gradingStatus, score }) => { /* grading update */ },
    },
};

New EventTypes (extends LcmEventTypes)ΒΆ

// app/eventTypes.js additions
export const SessionEventTypes = {
    // Session lifecycle
    SESSION_CREATED: 'session.created',
    SESSION_UPDATED: 'session.updated',

    // LabletRecordRun lifecycle
    RUN_CREATED: 'run.created',
    RUN_STATUS_UPDATED: 'run.status.updated',

    // LDS session
    RUN_LDS_PROVISIONED: 'run.lds.provisioned',
    RUN_LDS_ACTIVE: 'run.lds.active',
    RUN_LDS_PAUSED: 'run.lds.paused',
    RUN_LDS_ENDED: 'run.lds.ended',

    // Grading
    RUN_GRADING_STARTED: 'run.grading.started',
    RUN_GRADING_COLLECTING: 'run.grading.collecting',
    RUN_GRADING_COMPLETED: 'run.grading.completed',
    RUN_GRADING_FAULTED: 'run.grading.faulted',
    RUN_GRADING_SUBMITTED: 'run.grading.submitted',
    RUN_GRADING_REREAD: 'run.grading.reread',
};

9.10 SSE IntegrationΒΆ

SSE Event Map ExtensionsΒΆ

// sse/eventMap.js additions
export const sessionEventMap = {
    // LabletRecordRun lifecycle
    'run.created':          { busEvent: SessionEventTypes.RUN_CREATED,
                              storeAction: { slice: 'runs', action: 'upsertRun' } },
    'run.status.updated':   { busEvent: SessionEventTypes.RUN_STATUS_UPDATED,
                              storeAction: { slice: 'runs', action: 'upsertRun' } },

    // LDS session events
    'run.lds.provisioned':  { busEvent: SessionEventTypes.RUN_LDS_PROVISIONED,
                              storeAction: { slice: 'runs', action: 'updateRunLds' } },
    'run.lds.active':       { busEvent: SessionEventTypes.RUN_LDS_ACTIVE,
                              storeAction: { slice: 'runs', action: 'updateRunLds' },
                              toast: { type: 'info', msg: 'LDS session active' } },
    'run.lds.paused':       { busEvent: SessionEventTypes.RUN_LDS_PAUSED,
                              storeAction: { slice: 'runs', action: 'updateRunLds' } },
    'run.lds.ended':        { busEvent: SessionEventTypes.RUN_LDS_ENDED,
                              storeAction: { slice: 'runs', action: 'updateRunLds' },
                              toast: { type: 'warning', msg: 'LDS session ended' } },

    // Grading events
    'run.grading.started':  { busEvent: SessionEventTypes.RUN_GRADING_STARTED,
                              storeAction: { slice: 'runs', action: 'updateRunGrading' } },
    'run.grading.completed':{ busEvent: SessionEventTypes.RUN_GRADING_COMPLETED,
                              storeAction: { slice: 'runs', action: 'updateRunGrading' },
                              toast: { type: 'success', msg: 'Grading complete' } },
    'run.grading.faulted':  { busEvent: SessionEventTypes.RUN_GRADING_FAULTED,
                              storeAction: { slice: 'runs', action: 'updateRunGrading' },
                              toast: { type: 'danger', msg: 'Grading failed' } },
    'run.grading.submitted':{ busEvent: SessionEventTypes.RUN_GRADING_SUBMITTED,
                              storeAction: { slice: 'runs', action: 'updateRunGrading' },
                              toast: { type: 'success', msg: 'Score submitted' } },
    'run.grading.reread':   { busEvent: SessionEventTypes.RUN_GRADING_REREAD,
                              storeAction: { slice: 'runs', action: 'updateRunGrading' } },
};

9.11 UI API Client ExtensionsΒΆ

// api/sessions.js (NEW)
import { apiRequest } from './client.js';

export async function listSessions(filters = {}) { /* GET /api/sessions */ }
export async function getSession(sessionId) { /* GET /api/sessions/{id} */ }
export async function getSessionParts(sessionId) { /* GET /api/sessions/{id}/parts */ }

// api/lablet-record-runs.js (NEW)
export async function listRuns(filters = {}) { /* GET /api/lablet-record-runs */ }
export async function getRun(runId) { /* GET /api/lablet-record-runs/{id} */ }
export async function getRunsByInstance(instanceId) {
    /* GET /api/lablet-record-runs?lablet_instance_id={id} */ }

// LDS Session Operations
export async function provisionLdsSession(runId, data) {
    /* POST /api/lablet-record-runs/{runId}/lds/provision */ }
export async function pauseLdsSession(runId) {
    /* POST /api/lablet-record-runs/{runId}/lds/pause */ }
export async function resumeLdsSession(runId) {
    /* POST /api/lablet-record-runs/{runId}/lds/resume */ }
export async function endLdsSession(runId) {
    /* POST /api/lablet-record-runs/{runId}/lds/end */ }

// Grading Operations
export async function triggerGrading(runId, options = {}) {
    /* POST /api/lablet-record-runs/{runId}/grade */ }
export async function getGradingReportSummary(runId) {
    /* GET /api/lablet-record-runs/{runId}/grade/report/summary */ }
export async function getGradingReportUrl(runId) {
    /* GET /api/lablet-record-runs/{runId}/grade/report */ }
export async function submitGradingScore(runId) {
    /* POST /api/lablet-record-runs/{runId}/grade/submit */ }
export async function requestReread(runId) {
    /* POST /api/lablet-record-runs/{runId}/grade/reread */ }

// api/lab-records.js (existing β€” extended)
export async function getLabRecords(filters) { /* GET /api/lab-records */ }
export async function getLabRecord(id) { /* GET /api/lab-records/{id} */ }
export async function getLabRecordTopology(id) { /* GET /api/lab-records/{id}/topology */ }
export async function getLabRecordRevisions(id) { /* GET /api/lab-records/{id}/revisions */ }
export async function getLabRecordRuns(id) { /* GET /api/lab-records/{id}/runs */ }
export async function getLabRecordBindings(id) { /* GET /api/lab-records/{id}/bindings */ }
export async function startLab(id) { /* POST /api/lab-records/{id}/start */ }
export async function stopLab(id) { /* POST /api/lab-records/{id}/stop */ }
export async function wipeLab(id) { /* POST /api/lab-records/{id}/wipe */ }
export async function deleteLab(id) { /* POST /api/lab-records/{id}/delete */ }
export async function cloneLab(id, targetWorkerId) { /* POST /api/lab-records/{id}/clone */ }
export async function exportLabTopology(id) { /* POST /api/lab-records/{id}/export */ }
export async function importLabToWorker(workerId, topologyYaml) {
    /* POST /api/lab-records/import */ }
export async function discoverWorkerLabs(workerId) {
    /* POST /api/workers/{workerId}/labs/discover */ }

10. Implementation Gaps & RoadmapΒΆ

10.1 Gap AnalysisΒΆ

# Gap Current State Target State Priority Effort
G1 LabRecord has no lifecycle state machine Passive sync snapshot Full state machine with events πŸ”΄ Critical L
G2 No RuntimeBinding abstraction worker_id + lab_id strings RuntimeBinding VO 🟑 Medium M
G3 No LabletLabBinding entity cml_lab_id on LabletInstance Join entity with role/status πŸ”΄ Critical L
G4 No discovery-to-adoption flow Discovery creates orphan records Discovery β†’ UI link β†’ bind πŸ”΄ Critical L
G5 No lab reuse logic Always cold-import Resolve existing β†’ wipe β†’ start 🟒 High M
G6 No versioning/revisions No change tracking Revision history with checksums 🟑 Medium M
G7 No run history No execution tracking LabRunRecord per startβ†’stop cycle 🟑 Medium S
G8 No ExternalInterface VO Tags parsed ad-hoc in reconciler Structured VO on LabRecord 🟑 Medium S
G9 No Labs management page in UI Labs only visible in Worker modal Dedicated top-level page πŸ”΄ Critical L
G10 No lab-lablet binding UI cml_lab_id shown as string Binding cards with actions 🟒 High M
G11 No multi-lab support 1 lab per instance M:N via LabletLabBinding 🟑 Medium L
G12 No lab clone/export API Not implemented Clone, export, archive commands 🟑 Medium M
G13 LabRecordStatus enum missing No enum, raw CML states LabRecordStatus in lcm_core πŸ”΄ Critical S
G14 No SSE events for lab lifecycle Only worker.labs.updated Full lab event taxonomy 🟒 High M
G15 Reconciler doesn't resolve labs Always imports fresh Lab resolution phase in reconciler 🟒 High M
G16 No LabRecord read model in lcm_core Not needed previously LabRecordReadModel for controllers 🟒 High S
G17 No LabRecord repository interface Only LabRecordRepository in CPA Abstract interface in domain 🟑 Medium S
G18 No LabletRecordRun entity No runtime execution join Cross-aggregate mapping with LDS/grading state πŸ”΄ Critical L
G19 No Sessions page in UI Sessions only in LabletInstance context Top-level session-centric page with detail view πŸ”΄ Critical L
G20 No LDS IFRAME integration LDS login shown as external link Embedded LDS IFRAME with postMessage API 🟒 High L
G21 No grading IFRAME/panel Grading triggered via simple POST Score report IFRAME + inline summary panel 🟒 High M
G22 No port mapping resolution Ports extracted ad-hoc Structured allocation frozen at run start πŸ”΄ Critical M
G23 No session-part concept in UI Flat instance view SessionPart accordion with nested instances 🟒 High M
G24 No LabletRecordRun SSE events No real-time run lifecycle Full run/LDS/grading event taxonomy 🟒 High M
G25 No grading trigger from LDS events Manual grading only Auto-grade on LDS session end + on-demand 🟑 Medium M
G26 No LDS postMessage bridge No LDS-to-LCM communication postMessage API for pause/resume/grade_request 🟑 Medium M

10.2 Implementation PhasesΒΆ

Phase A: Domain Foundation (Sprint 1 β€” ~2 weeks)ΒΆ

Goal: Establish LabRecord as a first-class aggregate with proper domain model.

Task Files Gap
Create LabRecordStatus enum in lcm_core lcm_core/domain/enums/lab_record_status.py G13
Create RuntimeEnvironmentType enum lcm_core/domain/enums/runtime_environment_type.py G2
Create Value Objects control-plane-api/domain/value_objects/ G2, G8
Refactor LabRecord aggregate with state machine control-plane-api/domain/entities/lab_record.py G1
Create LabRecord domain events control-plane-api/domain/events/lab_record_events.py G1
Create LabletLabBinding entity control-plane-api/domain/entities/lablet_lab_binding.py G3
Create LabRecordReadModel in lcm_core lcm_core/domain/entities/read_models/lab_record_read_model.py G16
Create LabletLabBindingRepository control-plane-api/domain/repositories/ G3
MongoDB implementations control-plane-api/integration/repositories/ G3, G17
Unit tests for LabRecord aggregate control-plane-api/tests/domain/ β€”

Phase B: API & Commands (Sprint 2 β€” ~2 weeks)ΒΆ

Goal: Full CQRS command/query surface for LabRecord management.

Task Files Gap
Create CQRS commands (15 commands) control-plane-api/application/commands/lab/ G1, G4, G5, G12
Create CQRS queries (8 queries) control-plane-api/application/queries/lab/ G1
Create LabRecordsController (BFF) control-plane-api/api/controllers/lab_records_controller.py G9
Extend InternalController control-plane-api/api/controllers/internal_controller.py G4
Extend ControlPlaneApiClient lcm_core/integration/clients/control_plane_client.py G4
SSE event emission control-plane-api/application/services/ G14
Integration tests control-plane-api/tests/api/ β€”

Phase C: Controller Intelligence (Sprint 3 β€” ~1.5 weeks)ΒΆ

Goal: Lab discovery, reuse, and binding in lablet-controller.

Task Files Gap
Evolve LabsRefreshService β†’ LabDiscoveryService lablet-controller/application/hosted_services/ G4
Add lab resolution to LabletReconciler lablet-controller/application/hosted_services/lablet_reconciler.py G5, G15
Add binding management to reconciler Same G3
Add topology change detection lablet-controller/integration/services/cml_labs_spi.py G6
Run history recording Same G7
Unit tests for reconciler changes lablet-controller/tests/ β€”

Phase D: Frontend (Sprint 4 β€” ~2 weeks)ΒΆ

Goal: Full UI coverage for lab management.

Task Files Gap
Labs page component lcm_ui/src/components/labsPage/ or control-plane-api/ui/ G9
Lab Detail modal Same G9
Lab Record table with filters Same G9
Update Worker Detail Modal Labs tab Existing workerDetailsModal G10
Update Session detail view with lab bindings Existing labletsPage G10, G11
Add "Labs" nav item Navigation component G9
SSE subscriptions for lab events sseService G14
API client extensions apiClient G9
UI unit tests (vitest) lcm_ui/tests/ β€”

Phase E: Sessions, LDS & Grading Integration (Sprint 5 β€” ~3 weeks)ΒΆ

Goal: Session-centric UX with LDS IFRAME and grading pipeline.

Task Files Gap
Create LabletRecordRun entity + repository control-plane-api/domain/entities/lablet_record_run.py G18
Create LabletRecordRun status enums in lcm_core lcm_core/domain/enums/ G18
Create CQRS commands for run lifecycle (11) control-plane-api/application/commands/run/ G18
Create CQRS queries for run (4) control-plane-api/application/queries/run/ G18
Create LabletRecordRunController (BFF) control-plane-api/api/controllers/ G18
Port mapping resolution service control-plane-api/application/services/ G22
LDS adapter integration control-plane-api/integration/services/lds_adapter.py G20
GradingEngine adapter integration control-plane-api/integration/services/grading_adapter.py G21, G25
SSE events for run/LDS/grading lifecycle control-plane-api/application/services/ G24
Sessions page Web Component lcm_ui/src/components/sessionsPage/ or control-plane-api/ui/ G19
Session Detail page + SessionPart panels Same G19, G23
LcmLdsSessionPanel (IFRAME) Same G20, G26
LcmGradingPanel (IFRAME + summary) Same G21
LabletRecordRunCard component Same G18
PortMappingTable component Same G22
State store slices (sessions, runs) store.js / slices/ G19
SSE event map extensions sse/eventMap.js G24
API client modules (sessions, runs) api/sessions.js, api/lablet-record-runs.js G19, G18
Unit tests tests/ β€”

Phase F: Advanced Features (Sprint 6+ β€” optional)ΒΆ

Task Gap
Multi-lab lablet support (UI for binding multiple labs) G11
Lab clone across workers G12
Topology diff viewer (revision comparison) G6
Lab topology canvas visualisation (vis.js or d3) G9
Kubernetes runtime provider G2
Lab resource quotas and capacity planning β€”

11. Migration StrategyΒΆ

11.1 Backward CompatibilityΒΆ

The existing SyncLabRecordsCommand continues to work. The new DiscoverLabRecordsCommand is an evolution, not a replacement β€” it calls the same repository methods but adds:

  • Status tracking via LabRecordStatus
  • Topology change detection
  • Event emission

11.2 Data MigrationΒΆ

  1. Existing LabRecords β€” Add status field defaulting to the mapped CML state:
  2. CML DEFINED_ON_CORE β†’ LabRecordStatus.DEFINED
  3. CML STARTED/BOOTED β†’ LabRecordStatus.BOOTED
  4. CML STOPPED β†’ LabRecordStatus.STOPPED
  5. CML QUEUED β†’ LabRecordStatus.QUEUED

  6. Existing LabletInstances with cml_lab_id β€” Create LabletLabBinding records:

  7. For each LabletInstance with a non-null cml_lab_id:

    • Find or create LabRecord matching (worker_id, lab_id)
    • Create LabletLabBinding(role=PRIMARY, status=ACTIVE)
  8. Deprecate LabletInstance.state.cml_lab_id β€” Keep for read-only backward compatibility, but new code reads from LabletLabBinding.

11.3 Feature FlagsΒΆ

Flag Default Purpose
LAB_RECORD_LIFECYCLE_ENABLED false Enable new LabRecord state machine
LAB_REUSE_ENABLED false Enable lab reuse in reconciler
LAB_DISCOVERY_V2_ENABLED false Enable new discovery with status tracking
MULTI_LAB_ENABLED false Enable M:N lab-session bindings

Appendix A: CML Lab API Reference (v2.9)ΒΆ

Key endpoints used by the LabRecord lifecycle:

Endpoint Method Purpose Auth
/api/v0/labs GET List all lab IDs Bearer
/api/v0/labs/{id} GET Get lab details Bearer
/api/v0/labs/{id}/topology GET Get full topology Bearer
/api/v0/labs/{id}/state GET Get lab state Bearer
/api/v0/labs/{id}/start PUT Start lab Bearer
/api/v0/labs/{id}/stop PUT Stop lab Bearer
/api/v0/labs/{id}/wipe PUT Wipe lab nodes Bearer
/api/v0/labs/{id} DELETE Delete lab Bearer
/api/v0/import POST Import lab from YAML Bearer
/api/v0/labs/{id}/download GET Export lab YAML Bearer
/api/v0/labs/{id}/nodes GET List nodes Bearer
/api/v0/labs/{id}/nodes/{nid} GET Get node details Bearer

Appendix B: Topology YAML Schema ReferenceΒΆ

See TEST-LAB-1.1.yaml for a complete example.

nodes:
  - id: n0
    label: PC
    node_definition: ubuntu-desktop-24-04-v2
    tags: ["serial:4567", "vnc:4568"]
    interfaces:
      - id: i0, label: ens3, type: physical, slot: 0
    configuration:
      - name: ios_config.txt
        content: |
          hostname gateway
          ...

links:
  - id: l0
    n1: n0     # source node
    n2: n1     # target node
    i1: i0     # source interface
    i2: i1     # target interface
    label: ubuntu-desktop-0-ens3<->iosv-0-GigabitEthernet0/0

lab:
  title: "Lab at Wed 19:40 PM"
  description: ""
  notes: ""
  version: "0.3.0"

Appendix C: Files to Create/ModifyΒΆ

New FilesΒΆ

Path Description
lcm_core/domain/enums/lab_record_status.py LabRecordStatus enum + valid transitions
lcm_core/domain/enums/runtime_environment_type.py RuntimeEnvironmentType enum
lcm_core/domain/entities/read_models/lab_record_read_model.py LabRecordReadModel dataclass
control-plane-api/domain/value_objects/runtime_binding.py RuntimeBinding VO
control-plane-api/domain/value_objects/external_interface.py ExternalInterface VO
control-plane-api/domain/value_objects/lab_topology_spec.py LabTopologySpec VO
control-plane-api/domain/value_objects/lab_revision.py LabRevision VO
control-plane-api/domain/value_objects/lab_run_record.py LabRunRecord VO
control-plane-api/domain/entities/lablet_lab_binding.py LabletLabBinding entity
control-plane-api/domain/repositories/lablet_lab_binding_repository.py Abstract repository
control-plane-api/integration/repositories/mongo_lablet_lab_binding_repository.py MongoDB impl
control-plane-api/api/controllers/lab_records_controller.py BFF controller
control-plane-api/application/commands/lab/discover_lab_records_command.py Discovery command
control-plane-api/application/commands/lab/import_lab_record_command.py Import command
control-plane-api/application/commands/lab/bind_lab_to_lablet_command.py Bind command
control-plane-api/application/commands/lab/unbind_lab_from_lablet_command.py Unbind command
control-plane-api/application/commands/lab/clone_lab_record_command.py Clone command
control-plane-api/application/commands/lab/archive_lab_record_command.py Archive command
control-plane-api/application/commands/lab/update_lab_topology_command.py Topology update command
control-plane-api/application/commands/lab/record_lab_run_command.py Run record command
control-plane-api/application/queries/lab/get_lab_records_query.py List query
control-plane-api/application/queries/lab/get_lab_record_query.py Detail query
control-plane-api/application/queries/lab/get_lab_record_bindings_query.py Bindings query
control-plane-api/application/queries/lab/get_lab_record_revisions_query.py Revisions query
control-plane-api/application/queries/lab/get_lab_record_runs_query.py Runs query

Modified FilesΒΆ

Path Changes
lcm_core/domain/enums/__init__.py Export new enums
lcm_core/domain/entities/__init__.py Export LabRecordReadModel
lcm_core/domain/entities/read_models/__init__.py Export LabRecordReadModel
lcm_core/integration/clients/control_plane_client.py Add lab discovery/binding methods
control-plane-api/domain/entities/lab_record.py Full refactor with state machine
control-plane-api/domain/events/lab_record_events.py Add new domain events
control-plane-api/domain/entities/lablet_instance.py Add lab_bindings field
control-plane-api/api/controllers/internal_controller.py Add discover/bind/status endpoints
lablet-controller/application/hosted_services/labs_refresh_service.py Evolve to LabDiscoveryService
lablet-controller/application/hosted_services/lablet_reconciler.py Add lab resolution phase
UI components (multiple) Labs page, modal updates, nav, SSE

Appendix D: External Domain Models ReferenceΒΆ

This appendix documents the actual domain models from external Mozart microservices that LCM must integrate with. All models were extracted from the source code as of 2026-02-10.

D.1 Session Domain (session-manager)ΒΆ

Source: session-manager/src/Cisco.Mozart.Microservices.SessionManager.Domain/

The Session domain is the authoritative source for session lifecycle and structure.

Session (AggregateRoot)ΒΆ

The top-level container for a candidate's lab/exam experience.

Field Type Description
Id string Built from {environmentId}-{typeId}-{trackQualifiedName}-{guid}
TypeId string FK β†’ SessionType.Id (e.g., "exam-expert", "practice-lab")
EnvironmentId string FK β†’ DeliveryEnvironment.Id (e.g., "dev", "production")
LocationId string FK β†’ LabLocation.Id (physical site where session runs)
TrackQualifiedName string Parsed via TrackQualifiedName value object (e.g., "Exam CCIE Enterprise Infrastructure")
Authentication Authentication Scheme + properties (e.g., basic auth credentials)
Candidate CandidateInfo Id, FirstName, LastName, Email
ScheduledAt DateTimeOffset When the session is scheduled to start
Duration TimeSpan Total allowed duration
Properties IDictionary<string, object>? Extensible key-value properties
Status SessionStatus State machine (see below)
Parts IReadOnlyCollection<SessionPart>? Ordered list of session parts
AuthorizationPolicyId string? RBAC policy reference

Session Status State Machine:

EMPTY β†’ ASSIGNED β†’ INSTANTIATING β†’ PENDING β†’ RUNNING β†’ COMPLETED β†’ ARCHIVED
                                                  ↕
                                             PAUSING β†’ PAUSED

Valid transitions: Empty→Assigned, Assigned→Instantiating, Instantiating→Pending, Pending→Running, Running→Pausing, Pausing→Paused, Paused→Running, Running→Completed, Completed→Archived, *→Archived.

SessionPart (Entity, child of Session)ΒΆ

A content-scoped segment within a session (e.g., one lab module).

Field Type Description
Id string Built from {requirementId}-{sequence}
RequirementId string FK β†’ SessionPartRequirement.Id (defines what type of content this part requires)
Sequence ushort Order within the requirement group
FormQualifiedName string The specific content form assigned (e.g., "Exam CCIE TEST v1-US DOO 1.1")
Status SessionPartStatus Pending β†’ Running β†’ Completed β†’ Grading β†’ Graded β†’ Locked (also Paused)
PodStatus SessionPodStatus None β†’ Assigning β†’ Assigned
PodId string? FK β†’ Pod assigned to this part
ActivityRecords IReadOnlyCollection<SessionActivityRecord>? Start/end timestamps for activity tracking
Properties IDictionary<string, object>? Additional properties from pod assignment

SessionPartRequirement (Entity, child of SessionType)ΒΆ

Defines what kind of content a session part can accept.

Field Type Description
Id string Slugified from Name
Name string Descriptive name (e.g., "Lab", "Configuration")
TrackTypes IReadOnlyCollection<string>? Allowed track types (null = any)
TrackLevels IReadOnlyCollection<string>? Allowed track levels (null = any)
TrackAcronyms IReadOnlyCollection<string>? Allowed track acronyms (null = any)
ExamVersions IReadOnlyCollection<string>? Allowed exam versions (null = any)
ModuleAcronyms IReadOnlyCollection<string>? Allowed module acronyms (null = any)
PartsCount ushort? Max parts for this requirement (null = unlimited)
RequiresPod bool Whether this part type needs a Pod runtime

SessionType (AggregateRoot)ΒΆ

Defines a category of sessions and what part requirements they must satisfy.

Field Type Description
Id string Slugified from Acronym
Name string e.g., "Exam Expert", "Practice Lab Expert"
Acronym string e.g., "exam-expert"
Description string? Optional description
PartRequirements IReadOnlyCollection<SessionPartRequirement> What parts sessions of this type need
AuthorizationPolicyId string? RBAC policy

Examples of session types (from ScheduleManager): ExamExpert, PracticeLabExpert, ExamAPS, PracticeLablet, PracticeSession, ExamLablet, ExamSession.

LabLocation (AggregateRoot)ΒΆ

A physical lab room within a hosting site where sessions are delivered.

Field Type Description
Id string Stable identifier
HostingSiteLocationId string FK β†’ HostingSiteLocation.Id (parent site)
Type string Location type
Name string e.g., "Lab Room A"
QualifiedName string Built from {HostingSiteLocationName} {Name}
Acronym string Short code
Address Address Physical address
Proctor Contact Local proctor contact
TimezoneOffset TimeSpan UTC offset
ExamStartTime TimeOnly Standard exam start time at this location
SeatCapacity uint? Max concurrent seats

HostingSiteLocation (AggregateRoot)ΒΆ

A data center or physical site hosting pods and lab locations.

Field Type Description
Id string Stable identifier (e.g., "san-jose-building-c")
Name string e.g., "San Jose Building C"
Description string? Optional description
SiteNumber int Site number
RacksCapacity int? Total rack capacity
SupportTeams IReadOnlyCollection<Contact>? Support team contacts

Python Adapter Representation (lds-sessions-adapter)ΒΆ

The lds-sessions-adapter maintains a Python MozartSession entity that mirrors the .NET Session model as a local cache (Entity, not AggregateRoot). Key differences:

  • Uses aggregate_id (the session-manager's ID) + local id (built from {date}.{env}.{username}.{aggregate_id})
  • parts are MozartSessionPart objects containing form_qualified_name, pod_id, pod (local Pod entity), variables, devices_access_info, deadline
  • Status enum: EMPTY β†’ ASSIGNED β†’ INSTANTIATING β†’ PENDING β†’ RUNNING β†’ PAUSING β†’ PAUSED β†’ COMPLETED β†’ ARCHIVED
  • Handles LDS session linking (lds_session_id) and grading engine DTO conversion

D.2 Pod Domain (pod-manager)ΒΆ

Source: pod-manager/src/Cisco.Mozart.Microservices.PodManager.Domain/

The Pod domain manages physical and virtual lab infrastructure.

Pod (AggregateRoot)ΒΆ

A logical grouping of devices at a hosting site, assigned to sessions.

Field Type Description
Id string Built from {definitionName}-{hostingSiteLocationId}-{rackNumber} (slugified)
DefinitionId string FK β†’ PodDefinition.Id
HostingSiteLocationId string FK β†’ HostingSiteLocation.Id
RackNumber uint Physical rack number
QualifiedName string e.g., "Exam CCIE TEST v1 SJ 1"
ShortName string e.g., "TEST-SJ-01"
Status PodStatus State machine (see below)
PoolId string? Pool grouping
SessionId string? Currently assigned session
Error string? Fault error message
Devices IReadOnlyCollection<PodDevice> Named device slots with assigned physical devices
LabLocations IReadOnlyCollection<string> LabLocation IDs this pod is reserved for
InitializationReport PodInitializationReport? Init status details

Pod Status State Machine:

ASSEMBLING β†’ ASSEMBLED β†’ AVAILABLE β†’ ASSIGNED β†’ INITIALIZING β†’ READY β†’ OPERATING
                 ↑                                                        β”‚
                 └────────────────── Release β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                             FAULTED (from any active)
                                                             RETIRED (from any)

Valid transitions: Assembling→Assembled, Assembled→Available (when LabLocation added), Available→Assigned (to session), Assigned→Initializing, Initializing→Ready, Ready→Operating, Operating/Assigned/Ready→Available (release), *→Faulted, *→Retired.

PodDefinition (AggregateRoot)ΒΆ

Defines the blueprint for pods (what devices they contain, requirements, maintenance).

Field Type Description
Id string "pd-{name-slugified}"
Name string Must be a valid ExamQualifiedName or FormQualifiedName (e.g., "Exam CCIE TEST v1")
Description string? Optional
InitializationDelay TimeSpan How long to wait for pod init
Requirements PodRequirements Resource requirements
Maintenance PodMaintenance Maintenance schedule config
Devices IReadOnlyCollection<PodDeviceDefinition> Device blueprints
Dynamic bool true = on-demand virtual pods (e.g., CML), false = static physical pods
AuthorizationPolicyId string? RBAC policy

Key insight for LCM: CML workers hosting CML labs are Dynamic pods β€” instantiated on demand rather than mapped to pre-provisioned physical hardware.

PodDevice (Value Object, child of Pod)ΒΆ

A named device slot within a pod.

Field Type Description
Name string Device name within the pod (matches PodDeviceDefinition)
DefinitionId string FK β†’ DeviceDefinition.Id
Interfaces IReadOnlyCollection<PodDeviceInterface>? Network interfaces for accessing the device
DeviceId string? FK β†’ Device.Id (assigned physical/virtual device)
IsReady bool Whether the assigned device is ready

PodDeviceInterface (Entity)ΒΆ

An access interface on a pod device (how to connect to it).

Field Type Description
Id/Name string Interface name (e.g., "console", "ssh", "web")
Protocol DeviceInterfaceProtocol Protocol type
Host string Hostname or IP
Port int Port number
Authentication Authentication? Auth config for this interface
Configuration IDictionary<string, object>? Additional config

Device (AggregateRoot)ΒΆ

A physical or virtual device that can be assigned to pods.

Field Type Description
Id string {definitionId}-{shortGuid}
DefinitionId string FK β†’ DeviceDefinition.Id
Status DeviceStatus Preparing β†’ Online β†’ Offline β†’ Retired
Location DeviceLocation? Physical location
PodId string? Currently assigned pod

DeviceDefinition (AggregateRoot)ΒΆ

Defines a type of device.

Field Type Description
Id string Slugified from name
Type DeviceType Device category
Name string e.g., "Cisco ISRv", "Ubuntu Desktop"
Description string? Optional
Platform PlatformInfo Hosting platform configuration
ParentId string? Parent device definition (inheritance)
AuthorizationPolicyId string? RBAC
ExtensionData IDictionary<string, object>? Extensible metadata

D.3 Schedule Domain (schedule-manager)ΒΆ

Source: schedule-manager/src/domain/ (Python, Neuroglia framework)

The Schedule domain consumes CloudEvents from external systems (CCIEDB) and maintains a real-time database of schedule records. It uses Neuroglia's Entity[str] base class and emits outbound CloudEvents to trigger session creation/update/deletion.

Domain EntitiesΒΆ

ScheduleRecord (dataclass base)ΒΆ
@dataclass
class ScheduleRecord:
    id: str
    created_at: datetime
    last_modified_at: datetime
    lab_date: datetime
    scheduled_at: datetime
    data: Any                     # Typed in subclasses (e.g., CcieLabRecord)
    status: ScheduleStatus        # Enum: active, dropped, etc.
    schedule_id: ScheduleId       # Enum: exam_expert, practice_lab_expert
    trigger_status: TriggerStatus # Enum tracking outbound event state
    creator: str
    requestor: str
    timeslot_start: datetime
    timeslot_end: datetime
    dropped_at: datetime | None
CcieLabRecord (value object β€” candidate/exam details)ΒΆ
Field Type Description
candidate_id str Candidate identifier
first_name, last_name str Candidate name
email_address, cisco_id str Contact info
exam_track str CCIE track (e.g., Enterprise Infrastructure)
lab_location str Physical lab site
exam_qualified_name QualifiedName Form qualified name (str wrapper)
exam_attempts str Attempt number
employee str Y/N employee flag
elective, track_topic str? Optional track specialization
candidate_photo_signature_link str Photo/signature URL
lab_password str Lab access password
user_timeslot_start/end datetime? Candidate's timeslot
user_timeslot_duration str? Timeslot duration
age_group str Default: "ADULT"
ExamExpertScheduleRecord / PracticeLabExpertScheduleRecordΒΆ
class ExamExpertScheduleRecord(Entity[str], ScheduleRecord):
    data: CcieLabRecord           # Typed candidate/exam data
    schedule_id = ScheduleId.exam_expert
    # Sets timeslot_start/end from lab_date
    # Methods: update_lab_location(), update_both_location_lab_date()

class PracticeLabExpertScheduleRecord(Entity[str], ScheduleRecord):
    data: CcieLabRecord
    schedule_id = ScheduleId.practice_lab_expert
QualifiedNameToRulesetMappings (Entity β€” event scheduling rules)ΒΆ

Maps exam qualified names to EventRule sets for trigger timing:

class EventRule:
    name: str                     # Event type (e.g., "createtriggered")
    offset: str | List[str]       # ISO 8601 duration (e.g., "-PT2H" = 2h before)

class RulesetMap:
    qualified_name: str           # Exact or hierarchical match key
    enabled: bool
    rules: List[EventRule]

Uses hierarchical fuzzy matching: tries exact match β†’ progressive word trimming β†’ "default" fallback. Example: "Exam CCIE COL v1 DES 1.1" β†’ tries "Exam CCIE COL v1 DES" β†’ "Exam CCIE COL v1" β†’ ... β†’ "default".

Events (Entity β€” inbound event log)ΒΆ

Stores received CloudEvent records with typed data as a union of 5 CCIEDB integration event types:

CloudEvent Type Description
com.cisco.cciedb.schedulerecord.created.v1 New schedule record from CCIEDB
com.cisco.cciedb.schedulerecord.dropped.v1 Schedule cancelled
com.cisco.cciedb.schedulerecord.labdatechanged.v1 Lab date changed
com.cisco.cciedb.schedulerecord.locationchanged.v1 Location changed
com.cisco.cciedb.schedulerecord.changed.v1 General record change

RepositoriesΒΆ

Repository Entity Key Methods
ExamExpertScheduleRecordRepository ExamExpertScheduleRecord get_by_id, add_record, update_record, contains_record, find_record (filter+pagination), distinct, count_record
PracticeLabExpertScheduleRecordRepository PracticeLabExpertScheduleRecord Same interface

Event-Driven Workflow (Actual CloudEvent Chain)ΒΆ

CCIEDB β†’ com.cisco.cciedb.schedulerecord.created.v1
              β”‚
              β–Ό
    ScheduleManager: Creates ExamExpertScheduleRecord
              β”‚
              β–Ό (RulesetMap: offset triggers at lab_date - Xh)
    BackgroundJob: Evaluates EventRules against pivot_time (lab_date)
              β”‚
              β–Ό
    Outbound CloudEvents:
    β”œβ”€β”€ createtriggered.v1  β†’ SessionManager creates Session
    β”œβ”€β”€ droptriggered.v1    β†’ SessionManager drops Session
    └── updatetriggered.v1  β†’ SessionManager updates Session

LCM Relevance: The ScheduleManager is upstream of SessionManager β€” it triggers session creation based on schedule records and configurable timing rules. LCM doesn't interact with ScheduleManager directly but benefits from understanding the full chain: ScheduleRecord β†’ (trigger) β†’ Session β†’ SessionPart β†’ Pod/LabletInstance β†’ LabRecord β†’ GradingSession.


D.4 Form Content Packages (LDS)ΒΆ

A Form (identified by form_qualified_name) is the content definition assigned to a SessionPart. Each Form consists of three content packages delivered by LDS:

UserContent (content.xml)ΒΆ

The candidate-facing lab exercise content:

<lab_content version="3">
  <title>Lablet</title>
  <timing>
    <min_length_minutes>0</min_length_minutes>
    <max_length_minutes>300</max_length_minutes>
  </timing>
  <exercise_type>Lablet</exercise_type>
  <main_page>
    <diagram auto_title="true">images/topology.png</diagram>
  </main_page>
  <sections item_title_visible="false">
    <section>content/section_01.xml</section>
  </sections>
  <device>
    <device category="NA" device_label="ubuntu-desktop"
            coords="182,60,588,312" user_access_mode="web"/>
  </device>
</lab_content>

Key elements: <timing> (duration constraints), <exercise_type> (Lablet/Lab), <sections> (ordered content sections), <device> (device labels referenced in content, matching device_label in CML topology).

SupportContent (Grading Guide HTML)ΒΆ

A static HTML package with CSS/JS/images providing the proctor/grader reference guide. Structure: index.html, css/, js/, fonts/, images/, mosaic_meta.json.

GradingRulesContent (grade.xml)ΒΆ

Automated grading rules executed by the GradingEngine:

<grading-rules xmlns='http://www.w3.org/2009/grading'>
  <lab title='R_200-901_LAB-2.5.1' version='LAB-2.5.1'
       reportClass='Reports::LabletReport'/>
  <section index='0' tag='invariant' mode='concurrent'>
    <subsection index='1' description='vmanage-mock'>
      <verify subject='commandOutput' device='vmanage-mock'
              command='mockctl --json search vmanage /j_security_check'
              match='/^(.*)$/msig' status='positive' out='search_result'/>
    </subsection>
  </section>
  <section index='1' points='8' description='Content'>
    <subsection index='1' points='2' description='Check MFA step 1'
                domain='2.0 Understanding and Using APIs'>
      <verify subject='parse' device='vmanage-mock'
              string='$(search_result)' regexp='/status_200.*\d*$/m'
              mode='positive'/>
    </subsection>
  </section>
</grading-rules>

Key elements: <lab> (metadata), <section mode='concurrent'> (invariant checks), <section points='N'> (scored sections), <verify> (grading assertions referencing device labels and commands).

Cross-reference to LabRecord: The device attributes in both content.xml and grade.xml reference device labels that must match node labels in the CML topology (LabTopologySpec). This mapping is critical for the MVP Import Pipeline (Section 7.4).


D.5 Cross-Domain Relationship MapΒΆ

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ScheduleManager                                                         β”‚
β”‚  ScheduleRecord ──(triggers)──→ Session creation                        β”‚
β”‚    └── CcieLabRecord.exam_qualified_name β†’ FormQualifiedName            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚ createtriggered.v1
                                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  SessionManager                                                          β”‚
β”‚  Session ──(has)──→ SessionPart[] ──(assigned)──→ FormQualifiedName     β”‚
β”‚     β”‚                    β”‚                                               β”‚
β”‚     β”‚                    └──(pod)──→ PodId (FK to PodManager)            β”‚
β”‚     └──(at)──→ LabLocation ──(in)──→ HostingSiteLocation                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β–Ό                                 β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  PodManager               β”‚       β”‚  LCM (Lablet Cloud Manager)          β”‚
β”‚  Pod                      β”‚       β”‚                                      β”‚
β”‚  β”œβ”€β”€ PodDefinition        β”‚       β”‚  LabletInstance ←──(binds)──→ LabRecordβ”‚
β”‚  β”‚   (Dynamic=true β‰ˆ CML) β”‚       β”‚     β”‚                                β”‚
β”‚  β”œβ”€β”€ PodDevice[]          β”‚       β”‚     └──→ CMLWorker (EC2+CML runtime) β”‚
β”‚  β”‚   └── Device (physical)β”‚       β”‚                                      β”‚
β”‚  └── LabLocations[]       β”‚       β”‚  LabRecord.topology_spec             β”‚
β”‚                           β”‚       β”‚     ↕ maps to ↕                     β”‚
β”‚                           β”‚       β”‚  CML Lab nodes/links/interfaces      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                             β”‚ Pod.Devices[].hostname/port
                                             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  GradingEngine                                                           β”‚
β”‚  GradingSession ──(has)──→ GradingSessionPart[]                         β”‚
β”‚     β”‚                         β”‚                                          β”‚
β”‚     β”‚                         β”œβ”€β”€(pod)──→ Pod.Devices[].Interfaces[]     β”‚
β”‚     β”‚                         β”‚             (label, hostname, port, auth) β”‚
β”‚     β”‚                         β”œβ”€β”€(scoreReport)──→ SessionPartScoreReport β”‚
β”‚     β”‚                         β”‚                    (sections β†’ questions) β”‚
β”‚     β”‚                         └──(auditTrail)──→ AuditEntry[]            β”‚
β”‚     β”‚                                            (grade/submit/reread)   β”‚
β”‚     └──(lds)──→ LdsSessionReference (id + environment)                  β”‚
β”‚                                                                          β”‚
β”‚  GradingContext = Session + SessionPart + Ruleset β†’ ScoreReport          β”‚
β”‚  Ruleset = GradingToolkit (from grade.xml) + ScoringRequirements         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚ collects outputs via ROC
                                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LDS (Lab Delivery System)                                               β”‚
β”‚  LdsSession ──(parts)──→ LdsSessionPart[]                               β”‚
β”‚     └── FormQualifiedName β†’ UserContent + SupportContent + GradingRules  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

D.6 Grading Domain (grading-engine)ΒΆ

Source: grading-engine/src/Cisco.Mozart.Microservices.GradingEngine.Data/ (.NET 8/9, C#)

The Grading Engine is responsible for automated scoring of lab exam sessions. It operates on its own local representation of Session/SessionPart/Pod β€” not the same aggregates as session-manager or pod-manager, but references that carry the runtime device access information needed for output collection and grading.

Core EntitiesΒΆ

Session (GradingSession)ΒΆ
public class Session : IIdentifiable<string>
{
    string Id;                          // Grading session ID
    DateTimeOffset CreatedAt;
    DateTimeOffset? LastModified;
    string CandidateId;                 // Candidate taking the exam
    LdsSessionReference? Lds;           // Link to LDS session (id + environment)
    string Status;                      // SessionStatus: "created"
    List<SessionPart>? Parts;           // Graded parts
}

Note: This is NOT the same entity as session-manager's Session aggregate. It's a grading-engine-local representation that links to the canonical session via LdsSessionReference.

SessionPart (Graded Part)ΒΆ
public class SessionPart
{
    string Id;                          // Typically the form qualified name
    DateTimeOffset CreatedAt;
    DateTimeOffset? LastModified;
    DateTimeOffset? FirstGraded;
    DateTimeOffset? LastSubmitted;
    string Status;                      // SessionPartStatus (see below)
    string? StatusReason;
    Pod? Pod;                           // Device access info for grading
    SessionPartScoreReport? ScoreReport;
    List<AuditEntry>? AuditTrail;       // Action history
}
SessionPartStatus (Lifecycle)ΒΆ
Status Description
created Part created, not yet graded
grading Grading in progress (collecting outputs, evaluating rules)
reviewing Graded, under review, pending submission
locked Submitted β€” cannot be edited
faulted Grading failed (output collection error, rule evaluation error)
SessionPartAction (Available Operations)ΒΆ
Action Description
grade Trigger grading (collect outputs, evaluate rules)
submit Submit score, locking the part
reread Unlock a submitted part for re-evaluation
assign-pod Assign a Pod (with device access info) to the part
update-pod Update the Pod's device information
unassign-pod Remove Pod assignment
Pod (Device Access Info for Grading)ΒΆ
public class Pod
{
    string Id;                          // Same Pod ID from pod-manager
    List<Device>? Devices;              // Devices with access information
}

public class Device
{
    string Label;                       // Matches CML node label / content.xml device_label
    string Hostname;                    // Resolved hostname/IP from CML worker
    string Collector;                   // ROC service: "lds" (web) or "ios" (CLI)
    List<DeviceInterface> Interfaces;   // Access interfaces for output collection
}

public class DeviceInterface
{
    string Name;                        // Interface name
    string Protocol;                    // ssh, telnet, https, etc.
    string Host;                        // Resolved host
    int Port;                           // Port number (from CML L3 interface)
    AuthenticationDefinition? Authentication;  // scheme + properties
    IDictionary<string, object?>? Configuration;
}

Critical insight: The Device.Label in grading-engine must match the device_label in content.xml and the node label in the CML lab topology (LabRecord.topology_spec). The Hostname and Port come from the deployed CML lab's L3 interface assignments (managed by LCM through LabRecord).

LdsSessionReferenceΒΆ
public class LdsSessionReference
{
    string Id;                          // LDS session ID
    string Environment;                 // LDS environment (prod, staging, etc.)
}

Grading PipelineΒΆ

Ruleset (Grading + Scoring Rules)ΒΆ
public class Ruleset : IIdentifiable<string>
{
    string Id;                          // Session part ID it applies to
    GradingToolkit? Grading;            // Parsed from grade.xml (GradingRuleset)
    ScoringRequirements? Scoring;       // version, rereadScore, cutScore, minScore
}
GradingContext (Execution Context)ΒΆ
public class GradingContext(Session session, SessionPart part, Ruleset ruleset, bool recollect)
{
    Session Session;                    // The session being graded
    SessionPart Part;                   // The specific part
    Ruleset Ruleset;                    // Grading + scoring rules
    bool Recollect;                     // Re-collect device outputs?
    bool Regrade;                       // Re-evaluate previously collected outputs?
    IDictionary<string, object> Variables;  // Runtime variables during grading
    IDictionary<string, object?> Outputs;   // Collected device outputs
}
Score ReportsΒΆ
Report Level Entity Key Fields
Session SessionScoreReport status, score, minScore, maxScore, parts (dict of part reports), submittedAt, submittedBy
Part SessionPartScoreReport score, maxScore, sections[], variables, generation, revision, gradingToolkitPackageMetadata, gradingRulesetVersion, scoringRulesetVersion
Section SectionScoreReport Section-level scoring (from <section> in grade.xml)
Question QuestionScoreReport Question-level scoring (from <subsection> in grade.xml)

Remote Output Collection (ROC)ΒΆ

The grading engine uses two Remote Output Collector services to gather device outputs:

ROC Service Protocol Use Case
LDS ROC HTTPS (web) Collect outputs from web-based devices (e.g., vmanage-mock)
IOS ROC SSH/Telnet (CLI) Collect outputs from IOS/IOS-XE/NX-OS devices

Both ROC services connect to devices using the DeviceInterface information from the Pod assigned to the SessionPart. This is where LCM's LabRecord becomes critical β€” it provides the actual IP addresses, ports, and authentication details from the running CML lab.

LCM RelevanceΒΆ

  1. Device label mapping: CML lab node labels (LabRecord.topology_spec) must match Device.Label in the grading-engine Pod and device_label in LDS content packages
  2. Runtime access info: When a Pod is assigned to a GradingSession's SessionPart, the device hostnames/ports come from the deployed CML lab's L3 interfaces β€” information managed by LCM's LabRecord
  3. Lifecycle coordination: Lab must be running state before grading can collect outputs; LCM must ensure lab stability during grading windows
  4. Scoring requirements: ScoringRequirements.cutScore / minScore inform pass/fail β€” LCM may eventually surface these for operational visibility