Skip to content

fix(qa): #842 quota circuit-breaker + stale-evidence hygiene (6 remaining gaps) - #1042

Merged
100yenadmin merged 2 commits into
mainfrom
fix/qa-quota-stale-evidence-842
Jun 20, 2026
Merged

fix(qa): #842 quota circuit-breaker + stale-evidence hygiene (6 remaining gaps)#1042
100yenadmin merged 2 commits into
mainfrom
fix/qa-quota-stale-evidence-842

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Jun 20, 2026

Copy link
Copy Markdown
Member

Closes the 6 gaps #844 left open. Critical: stale-evidence — a quota-aborted sweep was reusing the prior run's RRI/duo scores byte-identical (the rc3 'story 4.0/mech 3.0 were rc2's files' bug). Fixes: (A) clear stale RRI.json at sweep start; (B) canary-abort writes an ABORTED RRI (shared write_aborted_rri()); (C) wipe stale duo artifacts before the duo; (D) ui_playtest early-429-exit → quota_exhausted bucket; (E) run_duo QUOTA ABORT (rc=2) + sweep skip; (F) score.sh 429 fast-fail sentinel. 6 new static contract tests (20 pass), bash -n clean.

Summary by CodeRabbit

  • Tests

    • Added a static contract test suite covering quota-exhaustion handling end-to-end across the QA pipeline.
  • Bug Fixes

    • Improved quota exhaustion detection (HTTP 429 / session limit) to fail fast and avoid misclassifying infrastructure limits as product issues.
    • Added a dedicated quota_exhausted failure bucket during Part B readiness, taking precedence over generic backend-not-ready results.
    • Ensured quota-abort outcomes are treated as aborted rather than release-ready.
  • Chores

    • Strengthened pre-run cleanup and standardized ABORTED evidence output to prevent stale artifacts from influencing results.

…s QUOTA-ABORT, never a junk RRI/score (#842)

PR #844 landed the core (sequential personas, quota_tripped, QUOTA_ABORT
sentinel, the rollup ABORTED status). This closes the SIX remaining gaps so a
mid-sweep account session-limit 429 can never masquerade as a product
measurement, and a quota'd run can never republish a previous run's evidence.

Fix A (qa/vm/sweep_v2.sh) — sweep-start cleanup now also `rm -f "$RES/RRI.json"`,
  so a sweep that quota-aborts before writing a fresh rollup can't leave the
  PREVIOUS run's RRI.json in place (the rc3 stale-RRI bug).

Fix B (qa/vm/sweep_v2.sh) — the canary QUOTA_ABORT path now writes the
  {"status":"ABORTED",…} RRI.json (it previously touched DONE + exited, leaving
  a stale RRI behind). Extracted a shared write_aborted_rri() helper and reused
  it at BOTH canary-abort sites and the post-batch QUOTA_ABORT short-circuit so
  the ABORTED JSON shape is identical at every quota exit.

Fix C (qa/vm/sweep_v2.sh) — wipe this run's stale duo artifacts
  (duo-{tolkien,angrydm,latency}.json + qa/transcripts/vm2-duo.{tolkien,angrydm}.json)
  BEFORE the duo runs, so the `[ -f ] && cp` only copies CURRENT-run output
  (rc3 republished rc2's byte-identical "story 4.0/mech 3.0" verbatim).

Fix D (qa/ui_playtest_app.sh) — the backend player-ready poll loop now greps
  backend.log for a 429/session-limit INSIDE the loop (after the kill -0 check),
  drops a QUOTA_EXHAUSTED sentinel + breaks early; the readiness-failure path
  checks the sentinel and emits a `quota_exhausted` bucket instead of
  mis-bucketing the corpse as backend_not_ready/no_actor. Added quota_exhausted
  to APP_FAILURE_BUCKETS_JSON.

Fix E (qa/run_duo.sh + qa/vm/sweep_v2.sh) — run_duo detects a session-limit 429
  in the DM cold-open ($COMBINED / $RUN.dm.err), logs "[duo] QUOTA ABORT", skips
  scoring, and exits rc=2 (before the empty-reply rc=1 abort). The sweep greps
  duo.log for that marker before copying duo scores → on a hit it writes
  QUOTA_ABORT + the ABORTED RRI and skips the rollup.

Fix F (qa/score.sh + qa/run_duo.sh) — score.sh adds a 429 fast-fail arm (no 3
  retries): on api_error_status==429 (or a session-limit body) it writes the
  sentinel {"quota_exhausted":true,"api_error_status":429} to $OUT and exits
  rc=2. run_duo's post-scoring check treats any lens carrying that sentinel as a
  quota abort (→ "[duo] QUOTA ABORT" + exit rc=2), never a valid scorecard,
  before the behavioral gate runs.

Tests (qa/test_release_gate_static.py) — 6 static grep-the-shell-source
  contracts mirroring test_release_gate_static.py style (no live runs):
  RRI.json in the cleanup rm; canary-abort writes an ABORTED RRI; duo-artifact
  rm precedes the duo call; quota_exhausted in APP_FAILURE_BUCKETS_JSON;
  score.sh 429 fast-fail arm (sentinel + rc=2); run_duo checks for QUOTA ABORT
  before scoring. All 20 tests in the file pass (14 pre-existing + 6 new).

bash -n clean on all four touched scripts.
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Quota/session-limit (HTTP 429) exhaustion is now treated as an infrastructure abort rather than a scoring or product miss across the QA pipeline. score.sh fast-fails with a quota_exhausted JSON sentinel; run_duo.sh checks for it before behavioral gating; ui_playtest_app.sh adds a quota_exhausted failure bucket; and sweep_v2.sh introduces a shared write_aborted_rri() helper and stale-artifact cleanup at every abort branch. Static shell-source contract tests verify the wiring end-to-end.

Changes

Quota/429 Circuit-Breaker Hardening

Layer / File(s) Summary
score.sh 429 fast-fail and quota sentinel
qa/score.sh
Inside the retry loop, detects HTTP 429 / session-limit conditions, writes {"quota_exhausted":true,"api_error_status":429} to $OUT, removes $RAW, and exits rc=2 before any retry or generic error handler fires.
run_duo.sh cold-open and post-scoring quota guards
qa/run_duo.sh
Adds an early guard post-DM-cold-open that searches transcript/stderr for session-limit/429 markers (exits rc=2), and a post-scoring guard iterating scorecard JSONs for quota_exhausted==true (exits rc=2) before behavioral gating and score capping.
ui_playtest_app.sh quota_exhausted bucket and Part B circuit-breaker
qa/ui_playtest_app.sh
Adds quota_exhausted to APP_FAILURE_BUCKETS_JSON; during the Part B readiness poll, scans backend.log for session-limit/429, writes a QUOTA_EXHAUSTED sentinel on match, and classifies the result as PART_B_RESULT=quota_exhausted superseding backend_not_ready.
sweep_v2.sh stale-artifact cleanup, write_aborted_rri helper, and quota-abort branches
qa/vm/sweep_v2.sh
Deletes $RES/RRI.json at startup; introduces write_aborted_rri() writing a standardized ABORTED JSON; replaces inline Python in all canary-abort, persona-batch-abort, and duo-phase-abort branches with the helper; removes stale duo JSON/transcript artifacts before the duo phase.
Static contract tests for quota circuit-breaker wiring
qa/test_release_gate_static.py
Adds QuotaCircuitBreakerStaticContractTests class with shell-source grep tests asserting startup RRI cleanup, canary-abort write_aborted_rri invocation, ABORTED RRI shape classification, stale duo artifact deletion ordering, ui_playtest_app.sh bucket/sentinel wiring, score.sh 429 fast-fail ordering, and run_duo.sh quota-abort ordering before assert_behavioral.py.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • electricsheephq/WorldOS#496: Introduced the split failure-bucket framework in qa/ui_playtest_app.sh that this PR extends with the quota_exhausted bucket and early-abort classification.
  • electricsheephq/WorldOS#537: Added E2BIG/empty-output vs API-error classification in qa/score.sh's retry logic—the same error-branching block this PR extends with the 429/quota fast-fail arm.
  • electricsheephq/WorldOS#844: Added earlier HTTP 429 session-limit quota-abort detection in qa/vm/sweep_v2.sh that marks runs as ABORTED—this PR strengthens and standardizes that same pattern with write_aborted_rri() and duo-phase coverage.

Poem

🐇 Hoppin' through the pipeline, I sniff a 429,
No more phantom scores when the quota's past its shrine!
write_aborted_rri() stamps the JSON clean and neat,
Stale artifacts swept away before the duo beat.
The rabbit says: abort with grace, not silence—
🎯 rc=2 for truth, not API defiance!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly references the main change (quota circuit-breaker and stale-evidence hygiene) and is specific to the changeset (6 gaps from #844).
Description check ✅ Passed The description covers the changes, impacts, and fixes comprehensively, though it lacks the explicit CLA checkbox section from the template.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@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: 2

🤖 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 `@qa/test_release_gate_static.py`:
- Around line 229-231: The loop variable `l` (lowercase L) in the generator
expression within the next() function call is ambiguous and flagged by Ruff
(E741). Replace all occurrences of the loop variable `l` with a more descriptive
name like `line` in both the iterator part and the condition that checks
`startswith("APP_FAILURE_BUCKETS_JSON=")` to improve readability and satisfy
static checks.

In `@qa/ui_playtest_app.sh`:
- Around line 969-988: The quota-exhaustion detection logic using grep to check
for "session limit|HTTP 429|hit your (session|usage) limit" in backend.log must
be repositioned to execute before the backend process liveness check (the kill
-0 command). Currently, if the backend process terminates due to a quota error,
the kill -0 check exits the loop before the quota detection runs, causing
incorrect bucketing as backend_not_ready instead of quota_exhausted. Move the
entire quota-check block (the grep condition with its log, touch
QUOTA_EXHAUSTED, and break statements) to occur earlier in the loop, before any
process termination checks that might prematurely exit the loop.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2ee74fb3-e967-427e-b2fc-a244ac9bef97

📥 Commits

Reviewing files that changed from the base of the PR and between 6f921be and db5ea5f.

📒 Files selected for processing (5)
  • qa/run_duo.sh
  • qa/score.sh
  • qa/test_release_gate_static.py
  • qa/ui_playtest_app.sh
  • qa/vm/sweep_v2.sh

Comment on lines +229 to +231
buckets_line = next(
l for l in source.splitlines() if l.startswith("APP_FAILURE_BUCKETS_JSON=")
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rename ambiguous loop variable at Line 230.

l is flagged by Ruff (E741) and is hard to read in this context. Rename it to line (or similar) to keep static checks green and improve clarity.

Suggested patch
-        buckets_line = next(
-            l for l in source.splitlines() if l.startswith("APP_FAILURE_BUCKETS_JSON=")
-        )
+        buckets_line = next(
+            line for line in source.splitlines() if line.startswith("APP_FAILURE_BUCKETS_JSON=")
+        )
📝 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
buckets_line = next(
l for l in source.splitlines() if l.startswith("APP_FAILURE_BUCKETS_JSON=")
)
buckets_line = next(
line for line in source.splitlines() if line.startswith("APP_FAILURE_BUCKETS_JSON=")
)
🧰 Tools
🪛 Ruff (0.15.17)

[error] 230-230: Ambiguous variable name: l

(E741)

🤖 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 `@qa/test_release_gate_static.py` around lines 229 - 231, The loop variable `l`
(lowercase L) in the generator expression within the next() function call is
ambiguous and flagged by Ruff (E741). Replace all occurrences of the loop
variable `l` with a more descriptive name like `line` in both the iterator part
and the condition that checks `startswith("APP_FAILURE_BUCKETS_JSON=")` to
improve readability and satisfy static checks.

Source: Linters/SAST tools

Comment thread qa/ui_playtest_app.sh
Comment on lines +969 to +988
# #842 Fix D (quota circuit-breaker): a DM cold-open that 429s on the account session limit writes
# "session limit" / "HTTP 429" into backend.log. WITHOUT this the poll just runs out its ~10-min cap
# and mis-buckets the corpse as no_actor/no_provider (the rc3 misattribution). Detect it INSIDE the
# loop, drop a QUOTA_EXHAUSTED sentinel, and break early so we abort honestly instead of waiting +
# mis-bucketing. This is an INFRA abort, NOT a product-readiness miss.
if grep -qiE "session limit|HTTP 429|hit your (session|usage) limit" "$RUNDIR/backend.log" 2>/dev/null; then
log "[B] QUOTA EXHAUSTED — backend DM cold-open hit the account session limit (HTTP 429); see $RUNDIR/backend.log. Aborting the ready-wait (INFRA abort, not a product miss)."
touch "$RUNDIR/QUOTA_EXHAUSTED"
break
fi
sleep 3
done
if [ "$ready" != "1" ]; then
# #842 Fix D: a quota 429 short-circuit takes precedence over the generic backend_not_ready
# classification — bucket it as quota_exhausted so the rollup attributes an INFRA abort, never a
# no_actor/no_provider product failure (the rc3 mis-bucketing the quota circuit-breaker exists to kill).
if [ -f "$RUNDIR/QUOTA_EXHAUSTED" ]; then
log "[B] backend never became player-ready — QUOTA_EXHAUSTED (account session limit) — see $RUNDIR/backend.log"
PART_B_RESULT="quota_exhausted"; set_bucket_pair B "$(bucket_pair quota_exhausted 'DM cold-open hit the account session limit (HTTP 429) — INFRA abort, not a product readiness miss')"; return 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C2 'kill -0 "\$B_BACKEND"|QUOTA_EXHAUSTED|session limit\|HTTP 429' qa/ui_playtest_app.sh

Repository: electricsheephq/WorldOS

Length of output: 2039


Move the quota-log check before the backend liveness check.

At line 968, the kill -0 check exits the loop if the backend process dies, bypassing the quota detection logic at lines 974–978. If a backend exits due to a 429 error, the quota check never runs, causing the failure to be mis-bucketed as backend_not_ready instead of quota_exhausted.

Suggested fix
-    kill -0 "$B_BACKEND" 2>/dev/null || { log "[B] backend exited early — see $RUNDIR/backend.log"; break; }
     # `#842` Fix D (quota circuit-breaker): a DM cold-open that 429s on the account session limit writes
     # "session limit" / "HTTP 429" into backend.log. WITHOUT this the poll just runs out its ~10-min cap
     # and mis-buckets the corpse as no_actor/no_provider (the rc3 misattribution). Detect it INSIDE the
     # loop, drop a QUOTA_EXHAUSTED sentinel, and break early so we abort honestly instead of waiting +
     # mis-bucketing. This is an INFRA abort, NOT a product-readiness miss.
     if grep -qiE "session limit|HTTP 429|hit your (session|usage) limit" "$RUNDIR/backend.log" 2>/dev/null; then
       log "[B] QUOTA EXHAUSTED — backend DM cold-open hit the account session limit (HTTP 429); see $RUNDIR/backend.log. Aborting the ready-wait (INFRA abort, not a product miss)."
       touch "$RUNDIR/QUOTA_EXHAUSTED"
       break
     fi
+    kill -0 "$B_BACKEND" 2>/dev/null || { log "[B] backend exited early — see $RUNDIR/backend.log"; break; }
🤖 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 `@qa/ui_playtest_app.sh` around lines 969 - 988, The quota-exhaustion detection
logic using grep to check for "session limit|HTTP 429|hit your (session|usage)
limit" in backend.log must be repositioned to execute before the backend process
liveness check (the kill -0 command). Currently, if the backend process
terminates due to a quota error, the kill -0 check exits the loop before the
quota detection runs, causing incorrect bucketing as backend_not_ready instead
of quota_exhausted. Move the entire quota-check block (the grep condition with
its log, touch QUOTA_EXHAUSTED, and break statements) to occur earlier in the
loop, before any process termination checks that might prematurely exit the
loop.

…tail (was masking as RELEASE_READY)

Adversarial review caught the load-bearing defect: the ABORTED RRI used {status:ABORTED, detail:...}
but evidence_audit.py keys on aborted:true + abort_detail — so a quota-aborted sweep read as
RELEASE_READY, the exact masking #842 prevents. Mirror release_readiness.py's shape. Lock it with a
static key-check + a functional test that runs the shape through evidence_audit.py (21 pass).

@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 `@qa/test_release_gate_static.py`:
- Around line 219-229: The subprocess.run call in the test function is using the
hardcoded string "python3" as the interpreter in the command list, which relies
on PATH resolution and triggers a security linting concern. Replace the
hardcoded "python3" string with sys.executable to use the active Python
interpreter instead, which is safer and more deterministic for the test
environment. Make sure to import sys at the top of the file if it is not already
imported.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf804c12-3f25-4d5c-be47-29cbdad74696

📥 Commits

Reviewing files that changed from the base of the PR and between db5ea5f and b65db55.

📒 Files selected for processing (2)
  • qa/test_release_gate_static.py
  • qa/vm/sweep_v2.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • qa/vm/sweep_v2.sh

Comment on lines +219 to +229
import json, subprocess, tempfile, os
rri = {"status": "ABORTED", "aborted": True, "abort_reason": "quota_session_limit",
"abort_detail": "newbie — quota resets ~3h", "build_sha": "deadbeef",
"release_ready": False, "note": "infra abort, not a product RRI"}
fd, path = tempfile.mkstemp(suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(rri, f)
out = subprocess.run(
["python3", str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
capture_output=True, text=True, timeout=30)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify there are no remaining partial-path python invocations in subprocess calls in this test file.
rg -nP 'subprocess\.run\(\s*\[\s*"python3"' qa/test_release_gate_static.py

Repository: electricsheephq/WorldOS

Length of output: 49


🏁 Script executed:

sed -n '219,229p' qa/test_release_gate_static.py

Repository: electricsheephq/WorldOS

Length of output: 690


Use sys.executable for the subprocess interpreter (line 228).

Calling "python3" by partial path relies on PATH and is exactly what Ruff S607 flags. Using the active interpreter is safer and more deterministic for this test.

Suggested patch
-        import json, subprocess, tempfile, os
+        import json, subprocess, tempfile, os, sys
@@
-                ["python3", str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
+                [sys.executable, str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
📝 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
import json, subprocess, tempfile, os
rri = {"status": "ABORTED", "aborted": True, "abort_reason": "quota_session_limit",
"abort_detail": "newbie — quota resets ~3h", "build_sha": "deadbeef",
"release_ready": False, "note": "infra abort, not a product RRI"}
fd, path = tempfile.mkstemp(suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(rri, f)
out = subprocess.run(
["python3", str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
capture_output=True, text=True, timeout=30)
import json, subprocess, tempfile, os, sys
rri = {"status": "ABORTED", "aborted": True, "abort_reason": "quota_session_limit",
"abort_detail": "newbie — quota resets ~3h", "build_sha": "deadbeef",
"release_ready": False, "note": "infra abort, not a product RRI"}
fd, path = tempfile.mkstemp(suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(rri, f)
out = subprocess.run(
[sys.executable, str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
capture_output=True, text=True, timeout=30)
🧰 Tools
🪛 ast-grep (0.43.0)

[error] 226-228: Command coming from incoming request
Context: subprocess.run(
["python3", str(ROOT / "qa" / "evidence_audit.py"), "--rri", path],
capture_output=True, text=True, timeout=30)
Note: [CWE-20].

(subprocess-from-request)

🪛 Ruff (0.15.17)

[error] 227-227: subprocess call: check for execution of untrusted input

(S603)


[error] 228-228: Starting a process with a partial executable path

(S607)

🤖 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 `@qa/test_release_gate_static.py` around lines 219 - 229, The subprocess.run
call in the test function is using the hardcoded string "python3" as the
interpreter in the command list, which relies on PATH resolution and triggers a
security linting concern. Replace the hardcoded "python3" string with
sys.executable to use the active Python interpreter instead, which is safer and
more deterministic for the test environment. Make sure to import sys at the top
of the file if it is not already imported.

Source: Linters/SAST tools

@100yenadmin
100yenadmin merged commit 8cb5cb5 into main Jun 20, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant