Skip to content

fix(rp2040): consume Windows port health in deploy selection and reacquisition#1153

Merged
zackees merged 11 commits into
mainfrom
fix/1147-rp2040-health-gate
Jul 23, 2026
Merged

fix(rp2040): consume Windows port health in deploy selection and reacquisition#1153
zackees merged 11 commits into
mainfrom
fix/1147-rp2040-health-gate

Conversation

@zackees

@zackees zackees commented Jul 23, 2026

Copy link
Copy Markdown
Member

Closes #1147 (tracked by meta #1149).

What changed

RP2040 deployment never treats a stale Windows CDC history record as a usable runtime port. Health from #1146 (PR #1150) now gates every step of the state machine, and the daemon no longer re-blesses the requested pre-flash COM name.

Files / state transitions

  • crates/fbuild-deploy/src/rp2040_target.rs
    • select_cdc_candidate filters known-unhealthy (Phantom / PresentProblem) records before serial/previous-port/new-port matching and returns the full health-carrying PicoCdcPort instead of a bare name. Unknown health (non-Windows) stays eligible.
    • resolve_requested_runtime_target fails an explicit --port COMx / SER= selector that matches a known-unhealthy record, printing health label, problem code, and instance ID instead of touching the stale handle (no 1200-baud touch on it).
  • crates/fbuild-deploy/src/rp2040.rs
    • wait_for_cdc_port_with_clock gains an injectable bounded openability probe: a selected candidate that fails open() is never returned; the loop keeps polling and the last open error lands in the timeout diagnostics.
    • CdcWaitTimeout carries last_open_error and emits phantom-aware recovery guidance (stale devnode identity + exact BOOT/RESET → RPI-RP2 steps + direct-port/cable advice).
    • resolve_post_flash_cdc confirmed-flash timeout note now includes the full diagnostics and guidance — flash success and CDC recovery are reported separately (port: None).
    • Rp2040Deployer::owns_post_flash_port_discovery() == true.
  • crates/fbuild-deploy/src/lib.rs — new default-false Deployer::owns_post_flash_port_discovery(); legacy deployers are unchanged.
  • crates/fbuild-daemon/src/handlers/operations/deploy.rs
    • The dangerous anchor deploy_port_str.or_else(|| deploy_result.port) is replaced by resolve_recovery_port(...): when the deployer owns port discovery, only the freshly catalogued, health-eligible, open-verified DeploymentResult.port is used for clear_preemption / post_deploy_recovery / monitor attach; None means nothing is touched or monitored (monitor was skipped response instead of /dev/ttyUSB0 fallback).
    • FBUILD_DEPLOY_PORT= stays empty (never stale) when no port was recovered, and the deployer's recovery diagnostic — previously discarded on success — is appended to the response message.
  • crates/fbuild-daemon/src/handlers/operations/deploy_port.rs
    • choose_deploy_port (RaspberryPi arm) partitions known-unhealthy device records out of auto-selection via partition_rp_candidates + rp_deploy_choice; they are reported in a warning (health/problem/instance, fbuild port scan pointer) and deploy continues on the BOOTSEL-volume path.

Deterministic tests added

rp2040_target.rs: phantom serial match never selected; present-problem previous port never reselected; healthy record wins over phantom history with the same serial; unhealthy new port creates no ambiguity; explicit selector on phantom fails with health details; Unknown health stays eligible.
rp2040.rs: open-probe failure never returned + lands in diagnostics; candidate that stays phantom is never returned (probe unreachable); confirmed flash + phantom history yields instance/health/problem/last-open-error/BOOTSEL guidance; existing #1095 delayed-handoff and renumbering tests updated for the probe seam.
deploy.rs (daemon): owned port discovery never substitutes the requested pre-flash port; legacy deployers keep requested-first order.
deploy_port.rs (daemon): phantom RP2040 CDC never auto-selected (with diagnostic warning); healthy selected while phantom is reported; present-problem excluded; unknown health eligible.

Validation

soldr cargo test -p fbuild-deploy        -> 241 passed, 0 failed
soldr cargo test -p fbuild-daemon --lib  -> 203 passed, 0 failed
soldr cargo test -p fbuild-cli           -> all suites ok
soldr cargo test -p fbuild-serial        -> unit tests ok (see note)
soldr cargo clippy -p fbuild-serial -p fbuild-deploy -p fbuild-cli -p fbuild-daemon --all-targets -- -D warnings  -> clean

Note: fbuild-serial boards.rs doctests (board_hint, family_for_vid_pid, vcom_for_env) fail locally without a populated FastLED/boards registry; this is identical on origin/main in this environment (this PR does not touch fbuild-serial or its dependencies) and green in CI.

Callers intentionally left on raw/lossy enumeration

Hardware

Not touched by this PR. The attached board (serial 5303284720C4641C) is deliberately preserved in the reproduced wedge (BOOTSEL RPI-RP2 on G:, phantom COM12, PICOBOOT MI_01 CM_PROB_FAILED_INSTALL) for the fbuild-only closeout run, which follows with #1152's --admin orchestration per the plan in #1152 (comment). Next child after merge: #1152.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Improved deploy recovery and monitoring by preferring the healthiest recovered post-flash runtime connection when available, and skipping post-flash monitoring when no healthy endpoint exists.
  • Bug Fixes
    • Prevented Raspberry Pi and RP2040/RP2350 runtime CDC selection from using known-unhealthy records, with clearer diagnostics for phantom/ambiguous devices and safer recovery behavior that avoids unsafe pre-flash substitutions.
    • Enhanced BOOTSEL CDC timeout/recovery guidance when flash succeeds but runtime CDC never becomes reachable.
  • Documentation
    • Updated WCHISP failure guidance to point to fbuild port scan for identifying the USB-ISP device.

…quisition

A retained CM_PROB_PHANTOM devnode whose serial still matches the board
could be selected pre-flash, touched at 1200 baud, and returned as the
post-flash deploy port. Health from #1146/#1150 now gates every step:

- rp2040_target: select_cdc_candidate returns a health-eligible
  PicoCdcPort (phantom/present-problem records are never selected);
  resolve_requested_runtime_target fails an explicit selector that
  matches a known-unhealthy record, with health/problem/instance detail
- rp2040: the post-flash wait adds a bounded openability probe seam;
  timeout diagnostics carry the last open error and phantom-aware
  BOOT/RESET recovery guidance; Deployer::owns_post_flash_port_discovery
  marks the RP2040 port as authoritative
- daemon: recovery/monitor port resolution no longer substitutes the
  requested pre-flash name when the deployer owns port discovery
  (deploy_port_str.or(result.port) inverted the trust order); flash
  success with an unrecovered CDC now surfaces the deployer's recovery
  diagnostic instead of discarding it; choose_deploy_port partitions
  known-unhealthy RP2040 records out of auto-selection with a
  diagnostic warning

Closes #1147

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6951133d-7f61-427c-9f62-ad164a6a659a

📥 Commits

Reviewing files that changed from the base of the PR and between 1589e7a and 3e7cb4d.

📒 Files selected for processing (3)
  • .github/workflows/dylint.yml
  • dylints/ban_raw_path_prefix_compare/src/allowlist.txt
  • dylints/ban_raw_path_prefix_compare/src/lib.rs

📝 Walkthrough

Walkthrough

The deployer contract now identifies post-flash port discovery ownership. RP2040 and Raspberry Pi CDC selection excludes known-unhealthy records, probes endpoint openability, reports richer diagnostics, and prevents monitoring from using stale pre-flash ports.

Changes

RP2040 CDC recovery and deploy selection

Layer / File(s) Summary
Healthy CDC candidate selection
crates/fbuild-deploy/src/rp2040_target.rs, crates/fbuild-daemon/src/handlers/operations/deploy_port.rs
Filters known-unhealthy CDC records from automatic selection, reports health details, and preserves healthy and unknown-health selection behavior.
Probe-confirmed post-flash CDC recovery
crates/fbuild-deploy/src/rp2040.rs
Adds bounded openability probing, tracks open failures, enriches timeout guidance, returns confirmed port values, and marks RP2040 discovery as deployer-owned.
Post-flash discovery ownership contract
crates/fbuild-deploy/src/lib.rs, crates/fbuild-daemon/src/handlers/operations/deploy.rs
Applies the ownership hook to recovery-port resolution, success messaging, monitoring, and legacy fallback behavior.
Supporting diagnostics and build updates
crates/fbuild-deploy/src/wchisp.rs, crates/fbuild-serial/..., crates/fbuild-config/..., crates/fbuild-toolchain/..., dylints/..., .github/workflows/dylint.yml
Updates failure guidance, documentation examples, platform warning attributes, formatting, test closure, lint imports and allowlists, and dylint alias refresh behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DeployHandler
  participant Rp2040Deployer
  participant CDCWait
  participant Monitor
  DeployHandler->>Rp2040Deployer: deploy and recover runtime CDC
  Rp2040Deployer->>CDCWait: select and probe CDC candidate
  CDCWait-->>Rp2040Deployer: confirmed endpoint or recovery diagnostics
  Rp2040Deployer-->>DeployHandler: deployment result and recovery port
  DeployHandler->>Monitor: monitor recovered endpoint or skip monitoring
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main RP2040 change: using Windows port health during selection and reacquisition.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1147-rp2040-health-gate

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
.github/workflows/dylint.yml

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/fbuild-daemon/src/handlers/operations/deploy.rs`:
- Around line 901-913: Update the recovery cleanup around resolve_recovery_port
and clear_preemption so deploy_port_str is cleared first when present, before
falling back to clearing recovery_port. Preserve the existing recovery-port
behavior for cases without a deploy port, ensuring owned post-flash recovery
releases the original pre-flash preemption even when the port is renumbered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a93469ac-0311-4871-8888-96b0c319bbbf

📥 Commits

Reviewing files that changed from the base of the PR and between 71096e2 and 40c138b.

📒 Files selected for processing (5)
  • crates/fbuild-daemon/src/handlers/operations/deploy.rs
  • crates/fbuild-daemon/src/handlers/operations/deploy_port.rs
  • crates/fbuild-deploy/src/lib.rs
  • crates/fbuild-deploy/src/rp2040.rs
  • crates/fbuild-deploy/src/rp2040_target.rs

Comment on lines +901 to 913
let port_discovery_owned = deployer_for_recovery
.as_ref()
.is_some_and(|deployer| deployer.owns_post_flash_port_discovery());
let recovery_port = resolve_recovery_port(
port_discovery_owned,
deploy_port_str.clone(),
deploy_result
.as_ref()
.ok()
.and_then(|result| result.port.clone())
});
.and_then(|result| result.port.clone()),
);
if let Some(ref p) = recovery_port {
ctx.serial_manager.clear_preemption(p).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=rust -C4 '(fn\s+clear_preemption|fn\s+preempt_for_deploy|last_disconnect|remove.*preempt)' crates/fbuild-daemon/src

Repository: FastLED/fbuild

Length of output: 12569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deploy.rs relevant sections =="
sed -n '400,470p' crates/fbuild-daemon/src/handlers/operations/deploy.rs
sed -n '850,930p' crates/fbuild-daemon/src/handlers/operations/deploy.rs
sed -n '1210,1265p' crates/fbuild-daemon/src/handlers/operations/deploy.rs

echo "== serial_manager references in deploy and manager =="
rg -n --type=rust -C3 'serial_manager|preempt|clear_preemption|preemption' crates/fbuild-daemon/src/handlers/operations/deploy.rs crates/fbuild-daemon/src h 2>/dev/null || true

echo "== locate serial_manager type definition/mocks/tests =="
rg -n --type=rust -C3 'struct .*Serial|serial_manager|preempt_for_deploy|clear_preemption|MonitorLease|preemption' crates/fbuild-daemon/src

Repository: FastLED/fbuild

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== shared serial manager files =="
fd -i 'serial' crates/fbuild-daemon/src -t f

echo "== serial manager outline and relevant preemption API =="
for f in $(fd -i 'serial' crates/fbuild-daemon/src -t f); do
  echo "--- $f"
  rg -n 'pub struct |mod |impl .*Serial|clear_preemption|preempt_for_deploy|has_clients|notify_port_renumbered|rebind_port_session' "$f" -C4
done

echo "== focused read files =="
sed -n '1,360p' crates/fbuild-daemon/src/handlers/operations/monitor.rs
sed -n '700,950p' crates/fbuild-daemon/src/handlers/operations/deploy.rs
sed -n '1220,1260p' crates/fbuild-daemon/src/handlers/operations/deploy.rs

Repository: FastLED/fbuild

Length of output: 26436


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files that define SharedSerialManager =="
rg -n --type=rust 'SharedSerialManager|impl .*SharedSerialManager|struct SharedSerialManager' crates -S

echo "== imports and usages with clearer boundary =="
rg -n --type=rust 'serial_manager|SerialManager|Monit' crates/fbuild-daemon/src/context.rs crates/fbuild-daemon/src/handlers/operations/deploy.rs crates/fbuild-daemon/src/handlers/operations/reset.rs crates/fbuild-daemon/src/handlers/operations/monitor.rs crates/fbuild-daemon/src/lib.rs crates/fbuild-daemon/src/main.rs -S

echo "== repository-wide serial manager exact matches =="
rg -n 'struct .*SerialManager|enum .*SerialManager|impl .*SerialManager|clear_preemption|preempt_for_deploy|notify_port_renumbered' crates -S

Repository: FastLED/fbuild

Length of output: 12021


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== manager preemption, client tracking, and renumber APIs =="
sed -n '50,140p' crates/fbuild-serial/src/manager.rs
sed -n '440,520p' crates/fbuild-serial/src/manager.rs
sed -n '640,730p' crates/fbuild-serial/src/manager.rs
sed -n '840,945p' crates/fbuild-serial/src/manager.rs
sed -n '1020,1105p' crates/fbuild-serial/src/manager.rs

echo "== monitor attach/detach/close preemption handling =="
rg -n --type=rust -C5 'attach_reader|detach_reader|release_writer|close_port_after_grace_if_idle|has_clients' crates/fbuild-serial/src/manager.rs

echo "== local behavioral verifier for preemption mapping logic =="
python3 - <<'PY'
requested = "COM12"
deployer_port = "COM27"
old_recovered = requested or deployer_port
new_recovered = deployer_port
preempted = requested
cleared_by_new = set(p for p in [deployer_port] if p)
print("preempted:", preempted)
print("ownership: requested", requested, "deployer_port", deployer_port)
print("old_recovery_port:", old_recovered)
print("new_recovery_port:", new_recovered)
print("cleared_preempted", preempted in cleared_by_new)
PY

Repository: FastLED/fbuild

Length of output: 22357


Release the pre-flash preemption after owned post-flash recovery. Owned deployers resolve recovery_port to the new port, so a renumbered pre-flash endpoint like COM12 never has its preempt_for_deploy() lease cleared when clear_preemption only runs on the recovered COM27 port. Clear deploy_port_str before falling back to recovery-port clearing.

🔒 Proposed fix
     let recovery_port = resolve_recovery_port(
         port_discovery_owned,
         deploy_port_str.clone(),
         deploy_result
             .as_ref()
             .ok()
             .and_then(|result| result.port.clone()),
     );
+    // Always release the preemption taken on the pre-flash port,
+    // even when owned discovery moved recovery to a renumbered endpoint.
+    if let Some(ref pre) = deploy_port_str {
+        if recovery_port.as_deref() != Some(pre.as_str()) {
+            ctx.serial_manager.clear_preemption(pre).await;
+        }
+    }
     if let Some(ref p) = recovery_port {
         ctx.serial_manager.clear_preemption(p).await;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let port_discovery_owned = deployer_for_recovery
.as_ref()
.is_some_and(|deployer| deployer.owns_post_flash_port_discovery());
let recovery_port = resolve_recovery_port(
port_discovery_owned,
deploy_port_str.clone(),
deploy_result
.as_ref()
.ok()
.and_then(|result| result.port.clone())
});
.and_then(|result| result.port.clone()),
);
if let Some(ref p) = recovery_port {
ctx.serial_manager.clear_preemption(p).await;
let port_discovery_owned = deployer_for_recovery
.as_ref()
.is_some_and(|deployer| deployer.owns_post_flash_port_discovery());
let recovery_port = resolve_recovery_port(
port_discovery_owned,
deploy_port_str.clone(),
deploy_result
.as_ref()
.ok()
.and_then(|result| result.port.clone()),
);
// Always release the preemption taken on the pre-flash port,
// even when owned discovery moved recovery to a renumbered endpoint.
if let Some(ref pre) = deploy_port_str {
if recovery_port.as_deref() != Some(pre.as_str()) {
ctx.serial_manager.clear_preemption(pre).await;
}
}
if let Some(ref p) = recovery_port {
ctx.serial_manager.clear_preemption(p).await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fbuild-daemon/src/handlers/operations/deploy.rs` around lines 901 -
913, Update the recovery cleanup around resolve_recovery_port and
clear_preemption so deploy_port_str is cleared first when present, before
falling back to clearing recovery_port. Preserve the existing recovery-port
behavior for cases without a deploy port, ensuring owned post-flash recovery
releases the original pre-flash preemption even when the port is renumbered.

zackees and others added 10 commits July 22, 2026 23:44
… gate

The Formatting and Check workflows have been red on main since #1145:
- four files (two from this branch, two pre-existing: board/loaders.rs,
  ch32v_core.rs) carry diffs under the pinned 1.94.1 formatter. Local
  cargo-fmt --all silently skips the daemon handler files on this
  checkout, so they were formatted with direct invocations.
- the #1150 health-classification helpers in fbuild-serial have no
  non-Windows, non-test caller, which RUSTFLAGS=-D warnings promotes to
  a hard error on the ubuntu/macos Check jobs. Annotate them
  cfg_attr(not(windows), allow(dead_code)) — they are fed by the
  Windows PnP enumeration and unit-tested cross-platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
board_hint, vcom_for_env, and family_for_vid_pid resolve through the
FastLED/boards registry at runtime; on doctest hosts (local and CI)
that cache is absent, so the three examples panicked on every Check
job since the vendored-table migration. no_run keeps them
compile-checked without requiring a populated registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci/find_direct_subprocess.py --fail has flagged the ch32v multilib
probe since it landed, keeping the Check jobs red. The spawn is a
short synchronous -print-multi-directory capability probe; mark it
allow-direct-spawn rather than routing a one-flag probe through the
subprocess facade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The USB VID/PID catalogue-literal guard has flagged this user-facing
string since the wchisp deployer landed, keeping Check (ubuntu) red.
Identity data belongs to the FastLED/boards registry; point the user
at 'fbuild port scan' instead of embedding 4348/1a86 literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Dylint crate-formatting gate (nightly style edition) has wanted
these two use-statement reorders since the lint landed; apply them so
the gate goes green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dylint

Same nightly-2026-03-26 style-edition reorders as ban_raw_subprocess;
verified with the crate-pinned formatter that both format-checked
dylint crates are now clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lint

Companion to the Python-lint marker: the workspace dylint pass wants
its own allowlist.txt entry for the same short synchronous
-print-multi-directory capability probe. ci/check_dylint_allowlists.py
passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ix_compare

WalkDir-derived paths are stripped against the exact root spelling they
were enumerated from (same-normalized by construction) and feed a
content hash, not a path-keyed cache lookup — the lint's own stated
exemption. Pre-existing debt unmasked once the earlier Dylint gate
failures were fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… edit

The previous round still flagged the freshly-allowlisted esp32
framework-library cache: the workspace dylint step served a compiled
lint from the restored build cache, ignoring the allowlist-only edit.
Touch the lint source (documenting the trap) so the fingerprint moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cargo-dylint loads the <name>@<toolchain> library copy, but the alias
loop only created it when absent. With target caching, a restored stale
alias therefore pinned every lint to its first-ever compiled allowlist:
rebuilt bare libraries (allowlist/source edits) were never loaded, which
is why ban_raw_path_prefix_compare kept flagging the freshly-allowlisted
esp32 framework-library cache. Overwrite the alias when the bare library
is newer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zackees
zackees merged commit 618001d into main Jul 23, 2026
91 of 93 checks passed
@zackees
zackees deleted the fix/1147-rp2040-health-gate branch July 23, 2026 10:35
@fastled-project-sync fastled-project-sync Bot moved this to Triage in FastLED Tracker Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

fix(rp2040): reject phantom CDC ports and give recoverable BOOTSEL guidance

1 participant