Skip to content

feat(config): add typed source schema with config lint/fmt/migrate#76

Merged
obcode merged 1 commit into
mainfrom
feat/config-source-schema
Jul 17, 2026
Merged

feat(config): add typed source schema with config lint/fmt/migrate#76
obcode merged 1 commit into
mainfrom
feat/config-source-schema

Conversation

@obcode

@obcode obcode commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Schritt 2a des glabs-web-Plans. Rein additiv — der bestehende viper-Auflösungspfad ist unangetastet, die Goldens sind unverändert.

Warum jetzt schon Nutzen entsteht

glabs ignoriert unbekannte Config-Keys stillschweigend. Ein Tippfehler sieht damit exakt aus wie eine Einstellung, die wirkt. glabs config lint sagt es laut — und findet in Deinen echten Kursdateien zwei Klassen toter Config, die seit jeher drinstehen:

problem  vss.blatt2.release.mergeRequest.dockerImages
    no such configuration key; it is silently ignored (dockerImages belongs
    under `release:`, not under `release.mergeRequest:`)
problem  vss.blatt0.clone.clone
    no such configuration key; it is silently ignored (the clone command has no
    such option; configuring `clone:` at all is what enables it)

clone.clone steckt in jeder einzelnen Kursdatei (fundc 8×, vss 3×, algdati 2×, mpd, fun). Die sechs Docker-Images in vss/blatt2 werden komplett ignoriert.

Keiner der beiden wird hier gefixt. Beide sind von den Goldens eingefroren und gehören in eigene Commits, damit der Migrationsdiff zuordenbar bleibt.

Dazu fmt (kanonische Form) und migrate (Legacy-Schreibweisen hochziehen). Alle drei brauchen Resolve() nicht — genau deshalb kommt 2a ohne Risiko aus.

Der Schema-Gedanke

config/source.go modelliert die Quellform: extends unaufgelöst, Course-Level-Students ungemerged. Das ist, was YAML ⇄ Mongo ⇄ GraphQL round-trippt. AssignmentConfig bleibt, was es ist: das aufgelöste Ergebnis. Drei Tag-Sets, eines pro Richtung. Pointer-Felder markieren „abwesend" exakt dort, wo der heutige Loader IsSet aufruft oder ein ok aus einem Map-Lookup prüft — und sonst nirgends.

Decode geht bytes → map (yaml.v3) → struct (mapstructure), nicht direkt über yaml.v3. Grund: viper matcht Keys case-insensitiv und echte Configs verlassen sich darauf (frombranch und fromBranch funktionieren heute beide). yaml.v3 matcht Tags exakt und hätte solche Keys still verschluckt. mapstructure foldet Case — das ist viper's Verhalten, viper dekodiert intern mit mapstructure.

Assignments werden einzeln dekodiert. mapstructures Metadata kann einen Key innerhalb einer ,remain-Map nicht benennen (es meldet [<interface {} Value>].clone.clone), und lint ist nur brauchbar, wenn es sagen kann, welches Assignment den Streu-Key trägt.

Zwei Tests tragen die Beweislast

TestSchemaClaimsEveryKeyInRealFixtures ist die Vollständigkeitsgarantie. Ohne sie wäre der Round-Trip-Test fast wertlos: ein Feld, das der Decoder gar nicht kennt, ist auf beiden Seiten abwesend und besteht den Test. Der Test nutzt mapstructures Unused — jeder Key, den kein Feld beansprucht, taucht dort auf. Ergebnis: das Schema beansprucht jeden Key in allen sieben Fixtures, bis auf die zwei bekannten Bugs.

Der Round-Trip wird auf Bedeutung geprüft, nicht auf Bytes — ein Struct hat keine Kommentare und keine Key-Reihenfolge. Zusätzlich verifiziert: fmt ist auf allen fünf echten Kursdateien bedeutungserhaltend und idempotent.

Eine bewusste Abweichung, dokumentiert

TestDecodeAcceptsAliasViperMissed: der typisierte Decoder akzeptiert approvalsRequired, das viper stillschweigend ignoriert (viper schreibt den Key klein, bevor die Alias-Tabelle ihn gegen die CamelCase-Schreibweise vergleicht → trifft nie).

Konsequenz für die Reihenfolge: in 2b würde der Golden legacy.approvalslist dadurch von 0 auf 1 kippen. Ich schlage vor, beide Bugs vorher im viper-Pfad zu fixen, jeder mit eigenem Commit und eigenem Golden-Diff — dann bleibt der Resolver-Tausch in 2b ein Null-Diff-Beweis. Beim dockerImages-Bug brauche ich dafür Deine Entscheidung: Code an die YAML anpassen oder YAML an den Code?

Verifikation

  • go test ./... grün; gofmt, go vet, golangci-lint sauber
  • Goldens unverändert → der Auflösungspfad ist nachweislich nicht angefasst
  • glabs config lint gegen die echten glabs-yaml-Dateien: findet beide Bugklassen, exit=1
  • glabs config fmt gegen alle fünf echten Dateien: bedeutungserhaltend + idempotent
  • Doku: docs/commands.md

🤖 Generated with Claude Code

glabs ignores unknown configuration keys silently, so a typo looks exactly like
a setting that works. Add `glabs config lint` to say so out loud, plus `fmt` and
`migrate` — all three built on a typed model of the course file.

Running lint over the real course files finds two classes of dead config that
have been there all along:

  - `clone.clone` in every single course file. There is no such option; merely
    configuring `clone:` is what enables cloning.
  - `vss.blatt2.release.mergeRequest.dockerImages`. The six images are nested
    under mergeRequest, but config/release.go:47 reads release.dockerImages, so
    none of them are applied.

Neither is fixed here. Both are frozen by the goldens and belong in their own
commits, so the migration diff stays attributable.

The schema (config/source.go) models the *source* form — `extends` unresolved,
course-level students unmerged — which is what round-trips YAML <-> Mongo <->
GraphQL. AssignmentConfig stays what it is: the resolved result. Three tag sets,
one per direction. Pointer fields mark "absent" exactly where the current loader
calls IsSet or checks a map lookup's ok, and nowhere else.

Decoding goes bytes -> map (yaml.v3) -> struct (mapstructure) rather than
straight through yaml.v3, because viper matches keys case-insensitively and real
configs rely on it: `frombranch` and `fromBranch` both work today. yaml.v3
matches tags exactly and would have silently dropped such keys. mapstructure
folds case, which is viper's own behaviour — viper decodes with mapstructure
internally.

Assignments are decoded one at a time. mapstructure's Metadata cannot name a key
inside a ",remain" map (it reports `[<interface {} Value>].clone.clone`), and
lint is only useful if it can say which assignment carries the stray key.

Purely additive: resolution still goes through viper, and the goldens are
untouched. The riskier half — replacing that path with a pure Resolve() over
this schema — is a separate commit with the goldens as its proof.

Two tests carry the weight. TestSchemaClaimsEveryKeyInRealFixtures is the
completeness guard: without it the round-trip test would be near-vacuous, since
a field the decoder never knew about is absent on both sides and passes. And the
round trip is asserted on meaning, not bytes — a struct has no comments and no
key order.

One deliberate divergence, documented in TestDecodeAcceptsAliasViperMissed: the
typed decoder accepts `approvalsRequired`, which viper silently ignores because
it lowercases the key before the alias table compares it against the camelCase
spelling. Fix that in the viper path first, in its own commit, or the resolver
swap stops being a zero-diff proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

github.com/obcode/glabs/v2/cmd/addgroupguests.go:12:	init						100.0%
github.com/obcode/glabs/v2/cmd/archive.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/check.go:10:		init						100.0%
github.com/obcode/glabs/v2/cmd/clone.go:48:		init						100.0%
github.com/obcode/glabs/v2/cmd/config.go:14:		init						100.0%
github.com/obcode/glabs/v2/cmd/config.go:117:		rewriteCourseFile				0.0%
github.com/obcode/glabs/v2/cmd/config.go:143:		decodeCourseFile				0.0%
github.com/obcode/glabs/v2/cmd/config.go:157:		coursesToProcess				0.0%
github.com/obcode/glabs/v2/cmd/config.go:170:		courseFilePath					0.0%
github.com/obcode/glabs/v2/cmd/delete.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/generate.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/protect.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/push.go:38:		init						100.0%
github.com/obcode/glabs/v2/cmd/report.go:14:		init						100.0%
github.com/obcode/glabs/v2/cmd/root.go:39:		Execute						0.0%
github.com/obcode/glabs/v2/cmd/root.go:43:		init						100.0%
github.com/obcode/glabs/v2/cmd/root.go:51:		er						0.0%
github.com/obcode/glabs/v2/cmd/root.go:56:		initConfig					0.0%
github.com/obcode/glabs/v2/cmd/setaccess.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/show.go:8:		init						100.0%
github.com/obcode/glabs/v2/cmd/update.go:12:		init						100.0%
github.com/obcode/glabs/v2/cmd/urls.go:34:		init						100.0%
github.com/obcode/glabs/v2/cmd/version.go:10:		init						100.0%
github.com/obcode/glabs/v2/config/assignment.go:11:	GetCourseURL					0.0%
github.com/obcode/glabs/v2/config/assignment.go:15:	GetAssignmentConfig				98.1%
github.com/obcode/glabs/v2/config/assignment.go:138:	RepoSuffix					100.0%
github.com/obcode/glabs/v2/config/assignment.go:157:	RepoBaseName					100.0%
github.com/obcode/glabs/v2/config/assignment.go:172:	RepoNameWithSuffix				100.0%
github.com/obcode/glabs/v2/config/assignment.go:176:	RepoNameForStudent				100.0%
github.com/obcode/glabs/v2/config/assignment.go:180:	RepoNameForGroup				100.0%
github.com/obcode/glabs/v2/config/assignment.go:184:	coursePath					100.0%
github.com/obcode/glabs/v2/config/assignment.go:195:	GetCourseSubgroupPath				0.0%
github.com/obcode/glabs/v2/config/assignment.go:199:	assignmentPath					100.0%
github.com/obcode/glabs/v2/config/assignment.go:210:	per						100.0%
github.com/obcode/glabs/v2/config/assignment.go:217:	description					100.0%
github.com/obcode/glabs/v2/config/assignment.go:227:	mergeRequest					94.4%
github.com/obcode/glabs/v2/config/assignment.go:271:	mergeRequestApprovals				95.9%
github.com/obcode/glabs/v2/config/assignment.go:351:	extractMergeRequestApprovalRulesRaw		100.0%
github.com/obcode/glabs/v2/config/assignment.go:364:	containsLegacyApprovalUsersKey			68.8%
github.com/obcode/glabs/v2/config/assignment.go:395:	mergeRequestApprovalSettings			95.8%
github.com/obcode/glabs/v2/config/assignment.go:442:	normalizeMergeRequestApprovalConfigKeys		100.0%
github.com/obcode/glabs/v2/config/course.go:15:		GetCourseConfig					100.0%
github.com/obcode/glabs/v2/config/course.go:28:		CourseExists					0.0%
github.com/obcode/glabs/v2/config/course.go:33:		StudentKey					0.0%
github.com/obcode/glabs/v2/config/decode.go:72:		DecodeCourse					100.0%
github.com/obcode/glabs/v2/config/decode.go:103:	DecodeCourseBody				87.5%
github.com/obcode/glabs/v2/config/decode.go:151:	decodeInto					75.0%
github.com/obcode/glabs/v2/config/decode.go:167:	prefixPaths					90.9%
github.com/obcode/glabs/v2/config/decode.go:188:	approvalsDecodeHook				100.0%
github.com/obcode/glabs/v2/config/decode.go:203:	normalizeLegacyKeys				100.0%
github.com/obcode/glabs/v2/config/encode.go:25:		EncodeCourse					66.7%
github.com/obcode/glabs/v2/config/inheritance.go:27:	assignmentIsAbstract				100.0%
github.com/obcode/glabs/v2/config/inheritance.go:48:	resolveAssignmentInheritance			100.0%
github.com/obcode/glabs/v2/config/inheritance.go:67:	mergedAssignmentMap				100.0%
github.com/obcode/glabs/v2/config/inheritance.go:104:	deepMerge					100.0%
github.com/obcode/glabs/v2/config/inheritance.go:125:	asStringMap					42.9%
github.com/obcode/glabs/v2/config/lint.go:29:		String						0.0%
github.com/obcode/glabs/v2/config/lint.go:39:		Lint						100.0%
github.com/obcode/glabs/v2/config/lint.go:68:		unknownKeyHint					75.0%
github.com/obcode/glabs/v2/config/lint.go:79:		lintAssignment					87.2%
github.com/obcode/glabs/v2/config/lint.go:177:		validWhenCommitAdded				66.7%
github.com/obcode/glabs/v2/config/lint.go:186:		sortedAssignmentNames				100.0%
github.com/obcode/glabs/v2/config/release.go:8:		release						100.0%
github.com/obcode/glabs/v2/config/release.go:22:	releaseMergeRequest				100.0%
github.com/obcode/glabs/v2/config/release.go:46:	dockerImages					100.0%
github.com/obcode/glabs/v2/config/repo.go:12:		startercode					100.0%
github.com/obcode/glabs/v2/config/repo.go:60:		branches					91.5%
github.com/obcode/glabs/v2/config/repo.go:141:		normalizeBranchRuleConfigKeys			100.0%
github.com/obcode/glabs/v2/config/repo.go:172:		defaultBranch					62.5%
github.com/obcode/glabs/v2/config/repo.go:187:		issues						100.0%
github.com/obcode/glabs/v2/config/repo.go:209:		clone						100.0%
github.com/obcode/glabs/v2/config/repo.go:231:		SetBranch					100.0%
github.com/obcode/glabs/v2/config/repo.go:235:		SetProtectToBranch				88.9%
github.com/obcode/glabs/v2/config/repo.go:253:		SetLocalpath					100.0%
github.com/obcode/glabs/v2/config/repo.go:257:		SetForce					100.0%
github.com/obcode/glabs/v2/config/seeder.go:15:		seeder						63.0%
github.com/obcode/glabs/v2/config/show.go:10:		Show						93.1%
github.com/obcode/glabs/v2/config/slug.go:44:		gitlabProjectPath				100.0%
github.com/obcode/glabs/v2/config/slug.go:60:		gitlabGroupPath					100.0%
github.com/obcode/glabs/v2/config/slug.go:75:		gitlabGroupPathSegment				100.0%
github.com/obcode/glabs/v2/config/slug.go:93:		parameterize					100.0%
github.com/obcode/glabs/v2/config/students.go:14:	SetAccessLevel					100.0%
github.com/obcode/glabs/v2/config/students.go:28:	accessLevel					100.0%
github.com/obcode/glabs/v2/config/students.go:43:	students					100.0%
github.com/obcode/glabs/v2/config/students.go:75:	mkStudents					94.4%
github.com/obcode/glabs/v2/config/students.go:108:	groups						100.0%
github.com/obcode/glabs/v2/config/types.go:5:		String						100.0%
github.com/obcode/glabs/v2/config/urls.go:9:		StartercodeURL					60.0%
github.com/obcode/glabs/v2/config/urls.go:18:		gitURLToWebURL					56.0%
github.com/obcode/glabs/v2/config/urls.go:61:		Urls						100.0%
github.com/obcode/glabs/v2/git/auth.go:11:		GetAuth						90.0%
github.com/obcode/glabs/v2/git/clone.go:18:		Clone						80.0%
github.com/obcode/glabs/v2/git/clone.go:38:		ProjectRepoUrl					100.0%
github.com/obcode/glabs/v2/git/clone.go:44:		localpath					100.0%
github.com/obcode/glabs/v2/git/clone.go:48:		clone						36.1%
github.com/obcode/glabs/v2/git/push.go:17:		Push						0.0%
github.com/obcode/glabs/v2/git/sourcerepo.go:19:	PrepareSourceRepo				0.0%
github.com/obcode/glabs/v2/gitlab/approvals.go:15:	applyMergeRequestApprovalRules			100.0%
github.com/obcode/glabs/v2/gitlab/approvals.go:19:	applyMergeRequestApprovalRulesForMemberCount	80.7%
github.com/obcode/glabs/v2/gitlab/approvals.go:222:	applyMergeRequestApprovalSettings		73.8%
github.com/obcode/glabs/v2/gitlab/approvals.go:285:	approvalRuleName				60.0%
github.com/obcode/glabs/v2/gitlab/approvals.go:295:	approvalRuleAppliesForMemberCount		100.0%
github.com/obcode/glabs/v2/gitlab/approvals.go:305:	resolveApprovalUsernames			77.8%
github.com/obcode/glabs/v2/gitlab/approvals.go:337:	resolveApprovalGroupIDs				75.0%
github.com/obcode/glabs/v2/gitlab/approvals.go:364:	isNumericIdentifier				75.0%
github.com/obcode/glabs/v2/gitlab/archive.go:14:	Archive						77.8%
github.com/obcode/glabs/v2/gitlab/archive.go:32:	archivePerStudent				90.9%
github.com/obcode/glabs/v2/gitlab/archive.go:54:	archivePerGroup					90.9%
github.com/obcode/glabs/v2/gitlab/archive.go:76:	archive						75.6%
github.com/obcode/glabs/v2/gitlab/branches.go:11:	syncConfiguredBranches				50.0%
github.com/obcode/glabs/v2/gitlab/branches.go:47:	defaultBranchName				37.5%
github.com/obcode/glabs/v2/gitlab/branches.go:65:	isBranchAlreadyExistsError			0.0%
github.com/obcode/glabs/v2/gitlab/check.go:9:		CheckCourse					90.6%
github.com/obcode/glabs/v2/gitlab/check.go:67:		checkStudent					100.0%
github.com/obcode/glabs/v2/gitlab/check.go:89:		checkDupsInGroups				100.0%
github.com/obcode/glabs/v2/gitlab/delete.go:11:		Delete						77.8%
github.com/obcode/glabs/v2/gitlab/delete.go:29:		deletePerStudent				100.0%
github.com/obcode/glabs/v2/gitlab/delete.go:40:		deletePerGroup					100.0%
github.com/obcode/glabs/v2/gitlab/delete.go:51:		delete						92.9%
github.com/obcode/glabs/v2/gitlab/generate.go:14:	Generate					38.9%
github.com/obcode/glabs/v2/gitlab/generate.go:56:	generate					0.0%
github.com/obcode/glabs/v2/gitlab/generate.go:273:	generatePerStudent				0.0%
github.com/obcode/glabs/v2/gitlab/generate.go:286:	generatePerGroup				0.0%
github.com/obcode/glabs/v2/gitlab/gitlab.go:13:		NewClient					80.0%
github.com/obcode/glabs/v2/gitlab/groups.go:13:		getGroupIDByFullPath				100.0%
github.com/obcode/glabs/v2/gitlab/groups.go:34:		getGroupID					100.0%
github.com/obcode/glabs/v2/gitlab/groups.go:47:		createGroup					83.3%
github.com/obcode/glabs/v2/gitlab/groups.go:88:		AddGroupGuests					0.0%
github.com/obcode/glabs/v2/gitlab/groups.go:145:	inviteGroupByEmail				0.0%
github.com/obcode/glabs/v2/gitlab/groups.go:163:	addGroupMember					0.0%
github.com/obcode/glabs/v2/gitlab/groups.go:200:	collectUniqueStudents				0.0%
github.com/obcode/glabs/v2/gitlab/issues.go:99:		getStartercodeProject				100.0%
github.com/obcode/glabs/v2/gitlab/issues.go:143:	replicateIssue					37.0%
github.com/obcode/glabs/v2/gitlab/issues.go:202:	resolveIssuePlanForReplication			83.3%
github.com/obcode/glabs/v2/gitlab/issues.go:245:	resolveIssueNumbersForReplication		75.0%
github.com/obcode/glabs/v2/gitlab/issues.go:254:	loadIssueForReplication				69.6%
github.com/obcode/glabs/v2/gitlab/issues.go:298:	listChildIIDsByParentLookup			46.2%
github.com/obcode/glabs/v2/gitlab/issues.go:346:	listChildIIDsByIssueGraphQL			82.4%
github.com/obcode/glabs/v2/gitlab/issues.go:381:	workItemTypeIDForName				0.0%
github.com/obcode/glabs/v2/gitlab/issues.go:406:	attachChildTaskToParent				0.0%
github.com/obcode/glabs/v2/gitlab/issues.go:429:	getProjectPathForGraphQL			25.0%
github.com/obcode/glabs/v2/gitlab/projects.go:12:	generateProject					77.5%
github.com/obcode/glabs/v2/gitlab/projects.go:102:	getProjectByName				80.0%
github.com/obcode/glabs/v2/gitlab/protect.go:15:	ProtectToBranch					77.8%
github.com/obcode/glabs/v2/gitlab/protect.go:33:	protectBranch					100.0%
github.com/obcode/glabs/v2/gitlab/protect.go:37:	protectBranchForMemberCount			71.8%
github.com/obcode/glabs/v2/gitlab/protect.go:119:	hasProtectedBranches				100.0%
github.com/obcode/glabs/v2/gitlab/protect.go:129:	hasMergeRequestApprovalConfig			100.0%
github.com/obcode/glabs/v2/gitlab/protect.go:137:	protectSingleBranch				72.2%
github.com/obcode/glabs/v2/gitlab/protect.go:191:	recreateProtectedBranch				80.0%
github.com/obcode/glabs/v2/gitlab/protect.go:229:	replaceBranchPermissions			88.9%
github.com/obcode/glabs/v2/gitlab/protect.go:245:	isProtectedBranchNotFoundError			75.0%
github.com/obcode/glabs/v2/gitlab/protect.go:254:	protectToBranchPerStudent			90.9%
github.com/obcode/glabs/v2/gitlab/protect.go:276:	protectToBranchPerGroup				72.7%
github.com/obcode/glabs/v2/gitlab/push.go:10:		Push						0.0%
github.com/obcode/glabs/v2/gitlab/report.go:14:		Report						78.9%
github.com/obcode/glabs/v2/gitlab/report.go:51:		ReportHTML					73.7%
github.com/obcode/glabs/v2/gitlab/report.go:86:		ReportJSON					77.8%
github.com/obcode/glabs/v2/gitlab/report_helper.go:17:	report						82.5%
github.com/obcode/glabs/v2/gitlab/report_helper.go:135:	projectReport					85.9%
github.com/obcode/glabs/v2/gitlab/seeder.go:21:		localpath					0.0%
github.com/obcode/glabs/v2/gitlab/seeder.go:25:		runSeeder					0.0%
github.com/obcode/glabs/v2/gitlab/seeder.go:100:	addAndCommit					0.0%
github.com/obcode/glabs/v2/gitlab/seeder.go:121:	push						0.0%
github.com/obcode/glabs/v2/gitlab/setaccess.go:14:	Setaccess					77.8%
github.com/obcode/glabs/v2/gitlab/setaccess.go:32:	setaccess					41.5%
github.com/obcode/glabs/v2/gitlab/setaccess.go:140:	inviteByEmail					100.0%
github.com/obcode/glabs/v2/gitlab/setaccess.go:155:	setaccessPerStudent				100.0%
github.com/obcode/glabs/v2/gitlab/setaccess.go:175:	setaccessPerGroup				80.0%
github.com/obcode/glabs/v2/gitlab/starterrepo.go:16:	pushStartercode					0.0%
github.com/obcode/glabs/v2/gitlab/update.go:15:		Update						60.0%
github.com/obcode/glabs/v2/gitlab/update.go:49:		update						31.6%
github.com/obcode/glabs/v2/gitlab/update.go:96:		updatePerStudent				100.0%
github.com/obcode/glabs/v2/gitlab/update.go:116:	updatePerGroup					100.0%
github.com/obcode/glabs/v2/gitlab/users.go:12:		getUser						95.0%
github.com/obcode/glabs/v2/gitlab/users.go:49:		getUserID					100.0%
github.com/obcode/glabs/v2/gitlab/users.go:63:		addMember					94.1%
github.com/obcode/glabs/v2/main.go:19:			main						0.0%
total:							(statements)					65.8%

@obcode
obcode merged commit 632c1df into main Jul 17, 2026
10 checks passed
@obcode
obcode deleted the feat/config-source-schema branch July 17, 2026 13:30
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