Skip to content

fix(objectql)!: a predicate rule's read through a master-detail FK asks the caller's read door for the header first — an unreadable header answers as a nonexistent one (#22892) - #22956

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22892-predicate-related-master-detail
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22892-predicate-related-master-detail

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22892
Clause-②: no (narrowing)

This implements ruling A (6115662038). The ruling extends ruling A on #22519 (6107211425) to a predicate rule's one-hop read through a master-detail FK: a header the caller cannot read is unavailable to the rule exactly as a header that exists nowhere is.

The defect, reproduced before the fix

ObjectQL.resolvePredicateRelated (#18682) resolves the related rows that a script / cross_field rule reads through a reference field (record.FIELD.COLUMN). For a related object with a tenant column, such as a tenanted master, it read the row elevated with no caller row bound. Validation rules run before the reference check. So for a non-system caller who may write a detail but cannot read its header, the rule judged the header's stored state.

The probe ran on main 018b077fbf as a scratch file, not committed. Its rig: one detail object whose rule refuses a line under a locked invoice, and a synthetic read scope in which the caller reads only the headers it owns.

header named by the insert answer before answer after
exists nowhere 400 VALIDATION_FAILED, _record rule_violation: "... the related record was not found" unchanged
unreadable, stored open 400 VALIDATION_FAILED, invoice reference_not_found the first row's answer, byte for byte
unreadable, stored locked 400 VALIDATION_FAILED, _record rule_violation: the rule's own message the first row's answer, byte for byte

The fix

The change is in packages/objectql/src/engine.ts, resolvePredicateRelated. When the traversed field is master_detail and the caller is not SYSTEM, the related read takes the write's two-read shape. It reuses masterDetailHeadersCallerReads, PR #22891's read door; no second helper is added.

  1. The caller's read door, under the caller's own context, projected to id. A header it does not return gets no row and no own-read set, so its binding is unresolved. That is exactly the binding a header that exists nowhere gets. A failed door read returns no header, so every header is missing, as fix(objectql)!: a detail write asks the caller’s read door for its master-detail header — an unreadable header answers as a nonexistent one (#22519) #22891 rules for the write's own header read.
  2. The existing elevated, tenant-kept read of the columns the rule names, only for the headers the door returned. A header the caller can read is still judged on its stored values.

Unchanged:

Docs:

  • The docblock's "like parent" rationale is rewritten to state the rule.
  • The inline "accepted cost" comment is narrowed: it no longer covers an unreadable master-detail header.
  • content/docs/permissions/system-context.mdx row 29b states the new bound. The census counts do not move, because no isSystem read was added: the new branch keys on the function's existing bound (check-system-context-census: OK).

Pins

The new file packages/objectql/src/engine-predicate-related-master-detail-header.test.ts has 22 tests on #22891's rig. Refusals assert the ADR-0112 envelope (status 400, code VALIDATION_FAILED), the field codes, and the message with the header id removed.

  • One answer for three cases: a header that exists nowhere, an unreadable open header and an unreadable locked header answer identically. This holds on insert, the batch insert, update by id, a repoint by id and the bulk update (5 doors).
  • A refused read of the header object answers as a header that exists nowhere, on the same 5 doors.
  • The validate() preview answers as the write does.
  • The partial insertMany: unreadable rows fail as the nonexistent row does, and readable rows are judged on their stored state.
  • The mechanism: the read door runs first, and no elevated read is issued for a header it did not return.
  • CONTROLS:

Ablation (one-off, not committed)

Verification

  • Full suite at fa29574fe1 (os-verify-lock VERDICT command-exit 0): @objectstack/objectql vitest run --project local gave Test Files 403 passed (403), Tests 7875 passed (7875).
  • After merging origin/main (6586f417fb):
    • pnpm install was not needed: the lockfile did not move.
    • A full turbo build passed (73/73).
    • pnpm --filter @objectstack/objectql typecheck passed: tsc --noEmit plus check:test-typecheck OK. The new test file is in the tsconfig.test.json program (--listFilesOnly count 1).
    • 10 targeted objectql files passed: the new file, fix(objectql)!: a detail write asks the caller’s read door for its master-detail header — an unreadable header answers as a nonexistent one (#22519) #22891's file, engine-predicate-relationship, engine-reference-tenant-scope, engine-required-when-parent, validate-preview-parent, rule-relationship-traversal, federated-injected-column-readers, engine-field-predicate-fault and engine-write-failure-log-level. Test Files 10 passed, Tests 233 passed.
    • 4 plugin-security files passed under the real SecurityPlugin (the suite aliases @objectstack/objectql to src): predicate-related-read-tenant-scope, delete-reference-cleanup-system-identity, write-preview-field-gate-parity and security-plugin. Test Files 4 passed, Tests 318 passed.
    • The merge brought no packages/objectql change, so the full objectql suite was not re-run on the merge commit.
  • Lint, narrowed and measured: eslint --no-inline-config --format json over the 2 changed .ts files.
    • Population: eslint.config.mjs's base block covers every **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} file outside packages/spec.
    • JSON count: 2 files, 0 errors, 0 warnings.
    • Invariance: the config never enables type-aware linting (no parserOptions.project, as the config itself states), so this diff cannot change the verdict on any untouched file.
    • The repository-wide pnpm lint is left to CI.
  • Gates: the union ran at final head 6586f417fb: all 98 commands derived by node scripts/pm/dispatch-gates.mjs --commands exited 0, exit codes captured before any pipe. --ran reconciled them: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN. An earlier union at fa29574fe1 had two PREREQUISITE NOT MET refusals (exit 3: check:skill-examples and check:dual-build-cjs-loads, for unbuilt packages). Both went green after a full build, and the union at the final head ran on a built tree.

Acceptance notes

  • packages/objectql/src/validation/rule-validator.ts is outside the claim's file surface and was not edited. Two of its docblocks no longer describe everything:

    • The RelatedFieldBinding docblock lists the related read's bounds (the projection, and the own read on an unwalled object) but not the master-detail read door.
    • The unresolved reason reads "a reference is stored but the related record was not found", which now also covers an unreadable header.

    Neither text is false; both are incomplete. carrier: none.

  • For an unreadable open header, the answer moves from the FK field (invoice reference_not_found) to the record level (_record rule_violation). That is the answer a nonexistent header already got on this object, which is the ruled outcome.

  • Cost: one extra id-projected read per master-detail field a rule reads through, once per write batch, for a non-system caller.


Generated by Claude Code

…ks the caller's read door for the header first

An unreadable header is now unavailable to the rule exactly as a header
that exists nowhere is (`unresolved`), whatever it holds. The elevated,
tenant-kept read follows only for the headers the door returned; system
callers and a plain lookup traversal are unchanged.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
…K — an unreadable header answers as a nonexistent one

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
… for the narrowing

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via resolvePredicateRelated (symbol, a method of class ObjectQL))
What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), master_detail (literal, 33 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 51b43a8dc45fef9d86229509ab6b2679ffa9d501 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 18fe10eeca2bb522520ca3cbc057d78fad81041d — the merge of head 6586f417fb3e86495afc657428ba703071d9e226 into base 51b43a8dc45fef9d86229509ab6b2679ffa9d501, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 18fe10eeca2bb522520ca3cbc057d78fad81041d && git checkout 18fe10eeca2bb522520ca3cbc057d78fad81041d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51b43a8dc45fef9d86229509ab6b2679ffa9d501 6586f417fb3e86495afc657428ba703071d9e226 && git checkout -B drift-repro 51b43a8dc45fef9d86229509ab6b2679ffa9d501 && git merge --no-ff 6586f417fb3e86495afc657428ba703071d9e226

node scripts/docs-audit/affected-docs.mjs --json 51b43a8dc45fef9d86229509ab6b2679ffa9d501

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 51b43a8dc45fef9d86229509ab6b2679ffa9d501 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6586f417fb3e86495afc657428ba703071d9e226
Local-runs: none

Inputs: card #22892 (body and all four comments: triage entry check 6113638846, ruling 6115662038 which binds, claim 6116757077, os-dev-report 6118217501); card #22519's ruling 6107211425 and PR #22891's contract record 6113514249 as the precedent this card extends; PR #22956 (body, its one comment — the docs-drift advisory — its file list, and the net diff of origin/main... the head: 4 files, +478 / −23, fetched into a review ref and read as objects, plus the two-dot diff against current main for the merge); the head's check-runs; and on origin/main only resolvePredicateRelated, masterDetailHeadersCallerReads and their call sites in packages/objectql/src/engine.ts, RelatedFieldBinding / RelatedUnavailableReason / traversalRefusal in packages/objectql/src/validation/rule-validator.ts, row 29b of content/docs/permissions/system-context.mdx, and ADR-0055 §3. Nothing was built, run or re-run. Disclosure rule kept: classes, positions and functions only.

Check-runs at the final read: 34 runs, no failure: 26 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 6 still in_progress — the six Test Core shards. Of the seven required contexts, Lint & Repo Gates, TypeScript Type Check, Build Core, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard are success; Test Core is not yet concluded on any shard. Check Changeset is success, and so are the four Type Check · jobs behind the aggregate. Every check:* gate — check:system-context-census, check:adr-0087-registration, check:changeset-no-major and check:durability-log-level among them — is therefore concluded green by Lint & Repo Gates on this head. The test families the pending shards carry (the new pin file, #22891's pins, the #18682 suite and plugin-security's real-rules suites among them) are concluded by those six runs, not by this record: the owning seat arms nothing until every shard reads success, and a red among them re-opens this record.

① Derived judgments

The accept-set change the diff implies — one narrowing, for non-system callers, on a predicate rule's one-hop read through a master_detail FK only. Read on the head, engine.ts resolvePredicateRelated (:9220): after the existing own-read branch for a related object no organization wall scopes, a new else if (:9358) keys on the function's existing bound (:9262, "not SYSTEM") and on the traversed field's type === 'master_detail' — the same arbiter the reference check uses to pick masterDetailHeaderExists (main :8661). It calls masterDetailHeadersCallerReads (:9366) with the deduplicated id set under the caller's own context, and the elevated, tenant-kept read then runs over readSet (:9370), the door's answer, instead of the asked ids. A header the door does not return never enters byId and the entry carries no ownRead, so the per-row binding (:9415) is unresolved — the same reason, and therefore the same traversalRefusal sentence ("the related record was not found", _record rule_violation inside 400 VALIDATION_FAILED), that a header existing nowhere already got on this path. Where it bites: a write that landed on main — an update by id or a bulk update of a detail whose stored FK names an unreadable header in a state the rule accepts, with no reference value re-supplied, so #22891's reference check never ran — is now refused; on insert and on a repoint the refusal's shape moves from the FK field's reference_not_found to the record-level rule refusal, which is the answer a nonexistent header already got there. Nothing loosens: the door's set is a subset of what the elevated read returned before. Right — this is the ruling's two-read shape, and it reuses PR #22891's door; the diff adds no method, so there is no second helper.

One answer for nowhere, unreadable-open and unreadable-locked, on every door and the preview — right. The change sits inside the one resolver every door calls: the validate() preview (:14083), insert single and array with insertMany as its partial form (:15043), update by id with the repoint (:16649), and the predicate/bulk update (:16945). None of the four call sites changes, so no door can keep the old read. Pinned in the head's new file: the three headers answer identically (envelope, field codes and message with the id removed) on insert, batch insert, update by id, repoint by id and bulk update; the preview's issue list is equal across the three; the partial insertMany fails the unreadable rows as it fails the nonexistent one and lands only the readable open one.

A refused door read binds as nothing returned — right, and in the fail-closed direction. masterDetailHeadersCallerReads (:8764) catches its own failure, logs once at warn and returns an empty set (:8787), so the branch takes the readSet.size === 0 exit (:9367): an empty byId, no ownRead, every header unresolved. The resolver's outer catch (which would have produced unreadable) is never reached by a door refusal, so a caller whose read of the header object is refused outright gets the nowhere answer and not a distinguishable one. Pinned for the refused-read caller on all five doors, readable headers included. warn is the right level: the failure reaches the caller as a refusal and nothing claims to persist.

The elevated read runs only for headers the door returned — right. :9370 reads $in over readSet. The mechanism pin asserts, for an unreadable header, exactly one non-elevated find on the header object and no elevated read, and for a readable one the door read followed by the elevated read of that id alone.

System callers unchanged — right. bound is false for isSystem, so neither the own-read branch nor the new one runs; masterDetailHeadersCallerReads would in any case return every asked id for a system context (:8771). Pinned: a system caller's insert and update are judged on the header's stored state whoever owns it, with zero non-elevated header reads.

Plain lookup traversal unchanged — right. The new branch keys on master_detail; a lookup to a walled object keeps the elevated read over ids (readSet defaults to ids). Pinned by the lookup-line control: the stored state is judged, locked refused and open landed, and no non-elevated read is issued. This is the ruling's scope line.

Unwalled related objects unchanged — right. The own-read branch runs first and is byte-identical apart from assigning readSet; a master-detail FK to a federated or untenanted master therefore still takes the own-read path, where every miss — stored or not — is already unreadable, one answer on that object, so the ruling's "unreadable equals nowhere" was already met there. The else if placement means the two bounds never compose, which is correct: both answer the same question.

#22891's three seams unchanged — right. resolveMasterDetailParent, resolveMasterDetailParents and masterDetailHeaderExists are outside the hunks; the diff touches the resolver's body and docblock only. The dev reports #22891's 30 pins green on both the mutated and the unmutated run; Test Core on the head is the merged-generation reading.

A readable header still judged on stored values, a hidden or masked state column included — right. The door is projected to id and its rows' contents are never consulted; the value read is the unchanged isSystem: true read. Pinned: locked refuses with the rule's own message and open lands on all five doors; a middleware that hides or masks the state column for the caller still yields the rule's verdict on the stored value.

Docblock and row 29b — right. The "Read under SYSTEM authority, like parent" section is gone; the new section separates the VALUES question (still SYSTEM, projection-bounded) from the ROWS question and states the three arms — the master-detail header through the door, an unwalled object through the own read, a plain lookup elevated alone — and the system caller's stand-down. The inline "accepted cost" comment now names the two inference channels that remain (a hidden column on a readable row, a plain lookup on a walled object) and excludes the unreadable master-detail header. Row 29b's "Get" column lists all three bounds a system caller skips, states that a non-system caller is held to all three with the one-answer rule and the stored-value rule, and states that a plain lookup to a walled object asks no door for any caller; each clause matches the code read above. The row's anchor is unchanged and no isSystem read was added, so the census does not move (dev-reported OK, and Lint & Repo Gates, which carries check:system-context-census, is success on the head).

Public surface — none changed. No export line in the diff; one local readSet binding and one branch inside a private method; no new method, type or option. @objectstack/objectql is the one published package touched; the test file and content/docs publish nothing.

ADR-0055 §3 — not touched and not contradicted. Its master-edit gate covers controlled_by_parent details in plugin-security; this change is a read bound inside the engine's rule evaluation and leaves that gate as it is.

Cost, noted and not a contract matter. One id-projected caller read per master-detail reference field a rule reads through, once per write batch, for a non-system caller — the shape #22891 already pays on the header binding.

② Semver level

Changeset: .changeset/22892-predicate-related-master-detail.md, '@objectstack/objectql': minor, body headed BREAKING (an accept-set narrowing) under the launch-window convention, carrying Clause-②: no (narrowing), the ADR-0087 marker not-required (no-migration-prescription) with its category walk, the FROM / TO statement, the bindings list and the one-line fix (grant the writer read on the master, or declare the detail controlled_by_parent). Matches what the diff publishes: nothing is added to the accept set (no); the accept set narrows for non-system callers on the doors named above ((narrowing), which AGENTS.md reads as BREAKING); no spec key, authorable spelling, export or stored shape moves, so no migration prescription is owed and not-required (no-migration-prescription) is the true category. No skip-changeset label (labels: documentation, size/l, tests, tooling).

The Clause-②: line agrees in all three places and is the true value. PR body line 2: Clause-②: no (narrowing). Changeset: Clause-②: no (narrowing). Claim 6116757077: Clause-②: no (narrowing). True, because the one direction the diff moves is refusing writes that landed (the stored-FK update doors) and reshaping other refusals into the nowhere answer; no write refused on main lands on the head. minor for a breaking narrowing follows the convention the changeset names and the precedent #22891's changeset set under the same ruling family; Check Changeset is success, and Lint & Repo Gates, which carries check:changeset-no-major and check:adr-0087-registration (dev-reported exit 0), is success on the head.

③ Boundary flags

open_questions: none declared.

Deviations (seven), each answered:

  1. Attribution: model-free commit trailers and the session-URL PR footer, over a harness reminder asking otherwise — accepted; this is the rule. The three authored commits each carry the Claude-Session plus Co-authored-by: Claude pair and no model identifier; the merge commit carries none.
  2. origin/main merged before the PR opened, the os-regen driver deferring content/docs/permissions/system-context.mdx — accepted, and verified here. The two-dot diff of the head against current main differs in that file by the row 29b line only, so nothing main brought to the page was dropped; the dev reports the deferral marker cleared and the pre-push hook passing, which the push itself attests. main has moved three commits past the merge (a board-snapshot workflow, a docs-deploy workflow and the v18 releases landing page); none touches packages/objectql, the page or the changeset; the queue rebuilds on current main, and no re-merge is owed before this record.
  3. The full objectql suite moved to the background and was waited on in-turn by recorded PID — accepted; a process matter with no file effect, and the head's Test Core is the authority on the merged generation.
  4. Lock contention, two queue-timeouts retried, no holder stalled — accepted; no file effect.
  5. A scratch probe file reproduced the three answers and was deleted before the first test commit — accepted, and verified: no commit on the branch adds it and the head's tree holds no such file.
  6. File surface kept; the rule-validator.ts docblock gap noted, not edited — accepted (the finding below).
  7. Worktree removed, nothing left uncommitted — accepted.

Out-of-scope finding (one) — answered, not escalated. packages/objectql/src/validation/rule-validator.ts is outside the claim's surface and was not edited. Read on main: the RelatedFieldBinding docblock names the projection and the unwalled own-read as the related read's bounds; the unresolved arm of RelatedUnavailableReason reads "the related record was not found"; and — a third site the dev did not list — the related option's docblock on the evaluation options says the read is made "like parent" under SYSTEM authority. After this diff each is incomplete and none is false: a header the door withholds IS not found as far as the rule is concerned, and the related read is again like parent on the master-detail arm. Prime Directive #10's test (a reproducible defect, a contract violation or an authoring trap) is not met, so an acceptance note is the right carrier and no card is owed; the engine docblock the ruling ordered rewritten is the one that states the rule, and it does. Non-blocking.

Governance: no governed-surface path in the file list; Governed Surface Queue Guard is success; 501 changed lines, under the 3,000-line threshold; head repo equals base repo; author objectstack-fleet[bot], assignee the card's; the PR is draft and unarmed, as the claim's "one contract-tier review before the queue" requires. This record is that review.

Implemented-by: claude/issue-22892-predicate-related-master-detail
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

Adopted by domain:engine#2 (session_01ADzJtzYTLUfgrRZHxkagkX) at 2026-10-12T05:44Z. The record came from an isolated at-tier reviewer. Its transcript shows read-only tool use: REST reads, a fetch into a review ref, and its only writes were its own scratch record and probes. The six Test Core shards that were pending at the record's final read have since concluded. On this head all 35 check-runs are concluded: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed. All seven required contexts are success.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants