Skip to content

release: v0.14.0-rc3 - #811

Merged
bussyjd merged 68 commits into
mainfrom
integration/v0.14.0-rc3
Aug 8, 2026
Merged

release: v0.14.0-rc3#811
bussyjd merged 68 commits into
mainfrom
integration/v0.14.0-rc3

Conversation

@bussyjd

@bussyjd bussyjd commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Rolls the v0.14.0 release train up to main. This supersedes #809 (rc2 → main) and #800 (rc1 → main), both of which are fully contained here.

main has zero commits that are not already in this branch, so this is a clean fast-forwardable roll-up: 62 commits, 69 files, +5,881 / −389.

Tagged and published as v0.14.0-rc3 (pre-release; releases/latest deliberately still reports v0.13.0, so the installer default is untouched).

What's in it

rc3 — two k3s blockers and two discovery fixes

rc2 — discovery and external buyer compatibility

Aggregate /.well-known/x402 for the whole stack; facilitator failures classified so buyer agents stop retrying unretryable errors; legacy network aliases alongside CAIP-2; SSE settlement deferred so a buyer whose connection dies mid-stream is not debited; secure operator branding previews; CLI version in the frontend footer; CodeQL go/log-injection remediation in the ForwardAuth middleware.

rc1 — agent chat widget, opt-in auth-capture unlock gate, agent model-strip.

Validation

This branch was put through pre-publish testing on three hosts before the tag was published.

  • spark1, full release smoke: 18 pass / 1 fail. Both live-network flows passed — flow-14-live-obol-base-sepolia and flow-13-dual-stack-obol.
  • spark2, full release smoke: zero failures attributable to this code. Nine flows failed; seven cascade from a single transient drpc.org Base-Sepolia 500 that killed Anvil's genesis in flow-10, one from a stuck kube-state-metrics pod, one is the stale assertion below.
  • 11 on-chain receipts on Base Sepolia — USDC and OBOL Permit2 settlements, a Permit2 approval, and ERC-8004 registrations. Every hash verified independently against the public RPC (status = 0x1), deliberately not the endpoint the flows themselves used.
  • A live nine-offer stack was upgraded to this build and re-probed end to end: every paid path returns a correct 402, aggregate discovery serves, and per-offer documents advertise methods that actually reach the payment gate.
  • Both fix(stack): two k3s blockers — loopback Endpoints, and the storefront preview host missing from /etc/hosts #808 fixes were confirmed on real hosts rather than in unit tests alone: the ollama Endpoints holds a routable IP, and storefront-preview.obol.stack survives repeated hermes rewrites of the managed hosts block.

Known issues carried into this PR

Recording these so they are not rediscovered later. None were judged release-blocking, and all are pre-existing rather than introduced here.

  1. obol stack down on k3s leaves host netfilter dirty. internal/stack/backend_k3s.go looks for /usr/local/bin/k3s-killall.sh, which never exists because obol installs k3s into its own BinDir rather than via the official installer. The fallback only kills containerd shims, leaving cni0, flannel.1 and CNI hostport DNAT rules behind — and because those rules match dst-type LOCAL, all host loopback :80/:443 is hijacked into a dead pod. Latent before rc3 only because k3s stack up failed earlier; making the lifecycle work exposes it.
  2. Discovery thins out for 30–90s after any controller restart. The upstream-OpenAPI cache starts empty and the shared bundle is rebuilt on every offer's reconcile, so offers that have not yet reconciled render from the fallback. Self-heals. fix(discovery): never pin a failed upstream OpenAPI probe #810 fixed the permanent form of this; the startup window needs the cache to distinguish "never fetched" from "fetch failed".
  3. release-smoke.sh cannot test the k3s backend at all. flows/lib.sh and flows/release-smoke.sh relink tools with for tool in kubectl helm helmfile k3d k9s openclawk3s is omitted, and backend_k3s.go has no PATH fallback. Given rc3 is entirely about k3s blockers, this is worth a one-word fix.
  4. flow-16-sell-agent step 13 asserts a contract that was deliberately removed. It requires agentModel in the 402 body, but mergeAgentExtras omits it on purpose and TestMergeAgentExtras_AddsAgentFieldsButNotModel asserts "agentModel must not be surfaced" — the rc1 agent model-strip. It fails identically on both test hosts, which is what confirms it is the flow that is stale. Fix the flow, not the verifier.
  5. flow-07-sell-verify steps 13, 15 and 16 pass for the wrong reason. Step 13 greps for "402" in kubectl logs … 2>&1 || true, and 402 is a substring of every x402 token in that output — including kubectl's own error text — so it cannot fail. Step 15 greps for tunnel in obol tunnel logs, which any failure message satisfies. Step 16 claims to verify all six conditions but runs two uncorrelated greps, so status: "True" from ModelReady satisfies it while Ready is False. Independently re-derived on both hosts.
  6. obol sell register's metadata write reverts with erc8004: setMetadata tx: execution reverted (custom error 0x7e273289), emitted only as a warning so steps_failed=0 hides it. Both live flows also leave transactions.registration empty in receipt-summary.json.
  7. Streaming settlement receipts are HTTP trailers. For SSE responses X-PAYMENT-RESPONSE ships as a trailer, which many proxies and clients drop. The deferral itself is correct — it is what fixes debit-after-disconnect — only the delivery mechanism is awkward.

Signing

All commits are signed except d7f191b9, the rc1 chat-widget merge, which is left as-is deliberately: it is baked into the already-published v0.14.0-rc1 tag, so re-signing it would fork released history.

Merging

Please merge, do not squash — the integration branch's merge structure is what maps released tags to their content.

bussyjd and others added 30 commits July 14, 2026 17:06
The upstream ethereum-node chart's reth subchart defaults to a tcp
liveness probe on p2p (30303) with failureThreshold 3 (~12 min grace).
Reth keeps that port closed while running post-crash check_consistency
recovery, which can take far longer on a large datadir — so the kubelet
SIGKILLs it mid-recovery, each kill adds more drift to heal on the next
start, and the node crash-loops permanently (observed: 637 restarts,
~129k blocks of StoragesHistory drift).

Override the probe with failureThreshold 10000 so it effectively never
fires; a genuinely dead reth exits the container on its own. Scoped to
reth only — geth/nethermind/besu probe http-rpc and erigon probes
metrics, none of which close during recovery.

Claude-Session: https://claude.ai/code/session_01VLQSsnH9WdAsnVYGTd2omr
buildResourceURL keyed the scheme off X-Forwarded-Proto alone, so behind
a TLS-terminating tunnel (edge https -> plaintext to Traefik -> verifier)
any route without an explicit X-Forwarded-Proto:https RequestHeaderModifier
produced http:// resource URLs in 402 challenges. The controller's
host-bound so-<name>-host routes carry that filter but the shared-origin
so-<name> routes do not — and being more path-specific for
/services/<name>, they win the match even on dedicated-hostname origins,
so challenges on those origins advertised http:// resources on an https
endpoint (strict v2 payment clients and discovery crawlers see a
scheme mismatch; observed live on 5 of 8 host-bound offers, #679).

resolveSiteURL already solved exactly this for 402-page links: default
https, downgrade only for hosts the stack serves locally over plain HTTP
(obol.stack, loopback, *.localhost/*.local), explicit signals always win.
Extract that resolution into resolveScheme and use it in both places, so
challenge resource URLs and page links can never disagree again.

Refs #679

Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
… openapi for inference/agent

Reported by a teammate during v0.14.0-rc0 field testing.

https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
Inline first-message fee capture for gate:auth offers: the SIWX session is
minted by settling an x402 auth-capture charge (EIP-3009 single-shot,
autoCapture) instead of a plain signature; subsequent requests ride the
session cookie free. The escrow enforces the client-signed fee split
(feeBps -> feeRecipient) on-chain at charge() time.

- internal/x402/authcapture.go: auth-capture requirement builder + signed
  payload validation (validateSignedUnlockRequirement pins every economic
  field of the client-signed requirement against config)
- internal/x402/unlockgate.go: unlock flow — 402 (x402Version 2) ->
  verify+settle against the signed payload -> mint SIWX cookie
- internal/x402/metrics.go: obol_x402_verifier_fee_revenue_atomic_total +
  settled_volume_atomic_total (network/asset/fee_recipient), excluded from
  route-pruning
- config: global authCaptureUnlock block (offerPrefix, price, payTo,
  feeRecipient, min/maxFeeBps, captureAuthorizer)

Config-gated, off by default. Settlement must use the client's signed
'accepted' requirement verbatim (PaymentInfo hash commits server-issued
deadlines; rebuilding drifts them and breaks the signature).

Proven end-to-end on Base Sepolia and Base mainnet (on-chain fee split,
cookie mint, free-ride, metrics materialization).

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
An Obol Agent (type=agent) runs its own model, skills, and memory — the buyer
never selects one, and Hermes ignores the chat-completions `model` field
(resolved from its own config). Surfacing the underlying model in the 402 page
is noise and exposes an implementation detail, and pay-agent's required
`--model` flag had no effect on agent calls.

- paymentrequired.go agentCopy: remove '(running <model>)', the '"model":' line
  in the example body, and '--model <id>' from the pay-agent example. The agent
  copy is now model-free.
- buy-x402 buy.py: pay-agent no longer accepts/sends --model (synthesised body
  is just {messages, stream}); usage strings updated.
- buy-x402 SKILL.md: pay-agent documented without --model.

go build + x402/buyer/embed tests green.

Claude-Session: https://claude.ai/code/session_01XgUndZjSoxr2jNNGG5sVYD
(cherry picked from commit e605f55)
mergeAgentExtras no longer adds extra.agentModel — an Obol Agent runs its own
model and the buyer never selects one, so the model id is an internal detail,
not buyer-facing info (it also rendered in the HTML 402 page's raw-JSON card).
agentSkills/agentRuntime still surface so clients can tell it's an agent.

Claude-Session: https://claude.ai/code/session_01XgUndZjSoxr2jNNGG5sVYD
(cherry picked from commit 92716d6)
…t offers

For agent offers the buyer never selects a model — the agent runs its own and
ignores the chat-completions `model` field — so the bazaar discovery example
now seeds the neutral 'your-model-id' placeholder instead of the real upstream
id (which also rendered in the HTML 402 page's embedded raw-JSON card). Inference
offers are unchanged: there the model IS buyer-selectable, so the real id stays.

Claude-Session: https://claude.ai/code/session_01XgUndZjSoxr2jNNGG5sVYD
(cherry picked from commit 0f86017)
…for agent offers

The /skill.md catalog (and its Service Details section) showed the agent's
underlying model in the Model column / **Model** bullet. An agent runs its own
model and ignores the request `model` field, so the id is an internal detail —
agent rows now render '—' and omit the **Model** bullet. Inference offers keep
their model (there the buyer selects it). Mirrors the 402 page/extra/bazaar
model-strip in internal/x402. The /api/services.json feed still resolves the
agent model on purpose (drives the storefront UI) — left untouched.

Claude-Session: https://claude.ai/code/session_01XgUndZjSoxr2jNNGG5sVYD
(cherry picked from commit 2a88c3f)
bussyjd and others added 16 commits August 5, 2026 21:28
`obol stack up` fails on the k3s backend with:

  UPGRADE FAILED: cannot patch "ollama" with kind Endpoints:
  Endpoints "ollama" is invalid: subsets[0].addresses[0].ip:
  Invalid value: "127.0.0.1": may not be in the loopback range

k3s runs directly on the host, so OllamaHostForBackend returns 127.0.0.1
and that value is stamped straight into the ollama Endpoints. Kubernetes
has rejected loopback addresses in Endpoints since v1.33, and rightly so:
inside a pod's network namespace 127.0.0.1 is the pod itself, not the
host, so the endpoint could never have routed anywhere useful.

OllamaHostIPForBackend now substitutes the host's primary routable IPv4
address whenever resolution lands on loopback. The guard sits in the
shared resolver rather than in a k3s branch, so it also covers Docker
runtimes that map host.docker.internal to loopback.

resolveHostIP in `obol sell` was a second copy of the same strategy and
had drifted with the same bug — it returned 127.0.0.1 for k3s and fed it
to createHostService, which builds an Endpoints object too. It now
delegates to the shared resolver instead of duplicating it.

Three existing tests asserted the old loopback values; they now assert
the contract that actually matters — the resolved address is a valid,
non-loopback IP.
The /storefront branding editor iframes storefront-preview.obol.stack, so
that name has to resolve locally. `obol stack up` appended it to the
hostname list at one call site — but EnsureHostsEntries replaces the
managed /etc/hosts block wholesale, and the four other call sites in
internal/hermes and internal/openclaw pass only agent hostnames.

`stack up` resumes agents after syncing defaults, so the hermes path runs
last and rewrites the block without the preview origin. Observed on a
real k3s stack: the block ended up with obol.stack and the two agent
hosts, the preview name did not resolve, and the editor's iframe had
nowhere to load from — while the HTTPRoute and its backend were healthy
and answered fine on a Host header.

The origin is a fixed property of every local stack, exactly like the
base domain, so it is now emitted unconditionally rather than depending
on which caller happens to write last. The constant moves to internal/dns
(a leaf package that owns the managed block) and internal/tunnel points at
it, keeping one source of truth.

Block rendering is split into buildHostsBlock so the guarantee is
testable without root.
Skip settle on client disconnect/write errors, classify facilitator rejections,
and teach HTTP buyers Bankr chat auto-pay while agent/inference use wallet-sign
plus long curl after live timeout and voucher failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Publish type-specific Poncho chat prompts alongside AgentCash/Bankr and
surface them in the storefront pill selector after a successful live pay test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Record that a Bankr App with pay:x402 + allowedHosts still hit rpc timeout
on Base mainnet agent offers; drop the Sepolia digression and clarify
HandleProxy settle-skip vs ForwardAuth verify-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
…njection)

CodeQL flagged four new go/log-injection alerts introduced by this PR in
internal/x402/forwardauth.go: the buyer's payment payload and the
facilitator's reject reason reach log.Printf unescaped, so a crafted
value containing CRLF can forge additional lines in the operator's log
(e.g. a fake "payment settled successfully" entry).

Fix at the producers rather than at each log call, so future call sites
inherit it: paymentPayloadSummary, facilitatorRejectDetail and the
normalize note now return CR/LF-stripped strings, and truncateForLog
sanitizes too (it carries the raw facilitator /verify body).

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
…-rc2

Brings the aggregate /.well-known/x402 + external buyer-tool compat work
along with its CodeQL go/log-injection fix.

Conflict resolutions (both additive — kept BOTH sides, not one over the
other):
  * catalog.go — the static-site match and content hash now fold in both
    the widget's embedded chat-vendor.js (rc2) and the new x402.json
    (#806). Dropping either side would silently pin a stale copy of that
    asset across controller upgrades via the skip-when-unchanged path.
  * forwardauth_test.go — kept rc2's TestBuildResourceURL_Scheme
    alongside #806's brokenPipeWriter settle-skip tests.

Also updates four widget-era test call sites in catalog_test.go and
hostoffer_test.go for #806's new wellKnownX402JSON parameter (they pass
"" — those tests do not exercise the aggregate x402 document). Without
this the package does not build its tests.

Replaces the unsigned a128bdf/dd9c5a1d/cdee3a22 with a single signed
merge; the resulting tree is byte-identical to cdee3a2.

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
…ecks

Drop chatty framing, require AgentCash/Poncho tx receipts, and teach Bankr
agent buyers to reuse the full accepts[] entry to avoid unsupported_scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…dates' into rc3-signed

# Conflicts:
#	obolup.sh
defaultPaidMethod flipped `type: http` offers without declared `methods`
from POST to GET. For a root-priced route that makes the advertised
operation unpayable.

renderStaticSite publishes the offer root as exactTo("/", "index.html"),
a GET-scoped Exact match, and its comment states the contract:

    Method-scoped to GET: discovery documents are read-only, and a
    root-priced offer (route pattern "/") advertises POST <origin>/ as
    its paid resource — an unscoped Exact "/" match would shadow that
    POST into the static httpd (501) instead of the payment gate.

So at the offer root GET is served by the landing page and only POST
falls through to the gate. The previous hardcoded POST was one half of
that two-sided contract; changing the advertised method without changing
the routing left discovery pointing at a 200 HTML page that can never
return 402.

Observed live on a deployed offer before this fix:

    GET  https://<offer-host>/  -> 200 text/html
    POST https://<offer-host>/  -> 402

with /.well-known/x402, openapi.json and skill.md all advertising GET.

defaultPaidMethod now takes the paid route's path and returns POST when
that route is root-priced ("", "/" or "/*"), GET otherwise. Sub-path paid
routes are unaffected and keep the GET default that stops OpenAPI and
AgentCash clients POSTing into a GET-only upstream (405), which is what
the GET default was introduced for.

The blast radius is any paid route declared WITHOUT `methods`, not only
offers with no route table at all — primaryPaidMethod checks
len(rt.Methods) > 0. A route table whose paid catch-all omits `methods`
was affected too.

Two existing assertions encoded the old behaviour for the root-priced
case and are updated with the reason. paidmethod_root_test.go adds the
check the route-surface suite was missing: the advertised method must not
be one the static site claims first. The release smoke could not catch
this because the payment gate matches on path only, ignoring method, so a
bare GET probe returns 402 regardless of what discovery advertises.
Carries d359a14: root-priced type:http offers advertise POST again, so the
advertised paid operation reaches the payment gate instead of the static
index.html. Adds paidmethod_root_test.go.
upstreamOpenAPICache.refresh keys on offer.Generation and short-circuits
once an entry exists for that generation. It recorded the fetch result
unconditionally — including nil — so a single failed probe pinned the
offer to its route-table fallback until someone edited the CR.

The blast radius is not one offer. reconcileStaticSite rebuilds the
SHARED bundle from this cache on every offer's reconcile, so one cached
nil overwrites a good document for the whole stack. A controller restart
empties the cache and gives each offer exactly one attempt, which is
where this is most likely to bite: any offer whose single post-restart
probe misses — upstream still rolling, cold start, transient 5xx, or the
probe's short timeout — silently degrades from one resource per real
paid route to a single root entry.

The cache doc comment already described it as holding "the last-good
upstream fetch"; treating a nil as good is what broke that contract.

refresh now leaves the generation unrecorded when a probe misses on an
offer that COULD serve a document, so the next reconcile retries and any
last-good doc survives: stale beats silently collapsed.

The distinction matters, because fetchUpstreamOpenAPI also returns nil by
design for agent and inference offers and for offers with no upstream
Service. Those can never serve a document, so their nil is terminal and
must still be cached — otherwise they would be probed on every single
reconcile forever. offerHasProbeableUpstream is extracted so refresh and
fetchUpstreamOpenAPI cannot disagree about which case they are in.

Observed on a live stack: after a controller image swap, three offers
dropped from 12/12/16 advertised resources to 1 while two others were
unaffected, then recovered as their reconciles landed. Verified the new
test fails without the guard (fetch called once instead of twice, and the
later success never cached) and passes with it.
bussyjd and others added 6 commits August 8, 2026 13:21
…cting it

Step 13 required agentModel in the 402 body. The verifier deliberately
omits it: mergeAgentExtras (internal/x402/verifier.go) writes only
agentSkills and agentRuntime, and TestMergeAgentExtras_AddsAgentFieldsButNotModel
asserts "agentModel must not be surfaced — the underlying model is an
internal detail, not buyer-facing". That is the rc1 agent model-strip.

So the flow has been asserting the inverse of a shipped, unit-tested
contract and failing ever since. It failed identically on two independent
hosts in the v0.14.0-rc3 validation, with everything around it green:
step 12 logged ModelReady=True UpstreamHealthy=True PaymentGateReady=True
RoutePublished=True, and the 402 carried a well-formed accepts[] with
extra={"agentRuntime":"hermes","agentSkills":[...]}.

The step now asserts the contract in BOTH directions: agentSkills and
agentRuntime must be present (a buyer needs them to choose the offer), and
agentModel must be absent (a leak is a regression). Retitled to match what
it checks.

Verified against the literal extra{} block both hosts produced: passes as
shipped, and fails with "agentModel leaked" if the id is ever surfaced.
…ettled

Closes the restart window that #810 left open.

The upstream-OpenAPI cache is process-local, so every controller restart
begins with no entry for any offer. reconcileStaticSite rebuilds the
SHARED bundle from that cache on EVERY offer's reconcile — including
reconciles belonging to other offers — so an offer that has not reconciled
yet was re-rendered from the route-table fallback and visibly lost its
advertised routes until its own reconcile landed.

Measured on a live nine-offer stack during the v0.14.0-rc3 validation:
after a controller image swap, five offers dropped from 11/12/12/12/16
advertised resources to 1 at ~t+30s and were fully recovered by ~t+90s.
Buyers crawling discovery inside that window see a single root entry
instead of the real paid routes.

#810 stopped a FAILED probe being pinned for the whole generation. It
could not fix this, because get() returns nil for "never fetched" and
"fetched, no document" alike, and the caller cannot tell them apart.

getSettled now returns that distinction, and buildOfferBundles takes the
currently-published ConfigMap data. While a probe is unsettled the already
served openapi.json and x402.json are kept — the ConfigMap survives the
restart even though the cache does not, so there is something correct to
hold on to. Stale beats thinner.

The other direction matters just as much and is tested: once a probe HAS
settled with no document, the fallback is the correct final answer and
must re-render, or an offer that legitimately drops its upstream OpenAPI
would serve the old document forever. A first-ever reconcile with nothing
published still renders the fallback rather than an empty document.

Verified TestBuildOfferBundles_UnsettledPreservesPublished fails with the
preserve branch disabled and passes with it.
…be killing a flow

Two harness defects found while validating v0.14.0-rc3 — a release whose
entire content was two k3s blockers.

1. The smoke could never exercise OBOL_BACKEND=k3s.

   flows/lib.sh and flows/release-smoke.sh relink tools into the flow
   workspace with a hard-coded list that omitted k3s, and
   internal/stack/backend_k3s.go resolves k3s at cfg.BinDir/k3s with no
   PATH fallback. The failure was deterministic and immediate:

     ✗ prerequisites check failed: k3s not found at
       /home/claude/obol-stack-rc3/.workspace/bin/k3s

   which cascade-failed every flow in the run. So the one backend rc3
   existed to fix was the one backend the release smoke could not touch.

   k3s now joins the relink loop. It is linked only when present, so
   k3d-only hosts are unaffected and it is deliberately NOT added to the
   required-tools list. release-smoke.sh instead fails fast with an
   actionable message when OBOL_BACKEND=k3s is requested and k3s is
   genuinely absent, rather than letting the first flow discover it.

2. flow-04 aborted the whole flow when loopback ingress was down.

   lib.sh runs under `set -euo pipefail`. dash_code() and
   dash_code_follow() are called inside command substitutions with no
   guard, so a curl exit 7 (CURLE_COULDNT_CONNECT) killed the script at
   whichever probe hit it first — losing every remaining step and leaving
   a truncated log instead of a failed assertion. Observed on spark1: the
   flow-04 log simply stops mid-step.

   Both helpers now swallow curl's status and emit "000", which is what
   curl's own -w already writes on a connection failure, so a dead ingress
   FAILS the step instead of ending the run.

   The other curl-in-substitution sites were checked and are already
   guarded (flow-04-agent.sh:130 with `|| true`, lib-dual-stack.sh:466
   with `|| rc=$?`); these two were the only unguarded ones.

Verified: bash -n clean on all three files; the old dash_code form aborts
under set -euo pipefail against an unreachable host while the new one
returns 000 and continues, and a healthy endpoint still returns its real
status code.
fix(flows): flow-16 asserts the agent model-strip instead of contradicting it
fix(discovery): hold published bundles while an upstream probe is unsettled
…uard

fix(flows): let the smoke test the k3s backend, and stop one dead probe killing a flow
@bussyjd
bussyjd merged commit d8ce8f2 into main Aug 8, 2026
14 of 15 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.

3 participants