Skip to content

Resolve the participant for the requested conversation before recording a vote - #3053

Merged
colinmegill merged 4 commits into
edgefrom
vote-misattribution-fix
Oct 13, 2026
Merged

colinmegill merged 4 commits into
edgefrom
vote-misattribution-fix

Conversation

@colinmegill

@colinmegill colinmegill commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Observable change

Participant tokens are now scoped against the conversation resolved by the route, including requests whose query and body name different conversations. A token for another conversation cannot supply its participant ID: membership is resolved for the target conversation and a replacement token is requested. Existing members of invite-only conversations are resolved before first-join restrictions apply.

The ordinary same-conversation-ID path adds no database query. Cross-conversation requests may perform cached conversation resolution, existing authorization reads and an indexed membership lookup; no report, export or external-service work is added to participation.

Tests

server/__tests__/integration/vote-misattribution.test.ts (23 cases): same-conversation tokens of every kind, cross-conversation tokens, conflicting query and body conversation ids, invite-only existing members, newcomers and owners, participationInit, and OIDC and comment-route controls. anonymous-jwt.test.ts now builds its XID participant through the real participation flow.

🤖 Generated with Claude Code

colinmegill and others added 4 commits October 13, 2026 01:49
…tion

A participant token minted for one conversation can be used to POST a vote
to a different conversation. Because participant ids are assigned per
conversation, the vote is stored against whichever participant holds that
id number in the target conversation, i.e. attributed to a different person.

Integration tests exercise anonymous, XID and standard-user participant
tokens (all affected), the raw OIDC access token (a control that resolves
the id fresh), the comment path (a control that re-resolves the id by user),
and a token carrying an id that does not exist in the target conversation.
Each reproduction asserts the correct behaviour, so it is red until the
defect is fixed. Votes are written only through the vote route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…ng a vote

A participant token carries the pid it was issued with, and pids are
assigned per conversation. ensureParticipant copied the request's uid and
pid into locals before the conversation-mismatch handler cleared them, so a
token presented in a different conversation kept its pid, and the
nonnegative-pid fast path used that pid as the requested conversation's
participant. The identity is now read after the handler has run, and a
token issued for another conversation never supplies this conversation's
pid: the participant is looked up (or created) for (zid, uid), which is
what the handler intends. A token used in the conversation it was issued
in takes the same path as before.

The integration suite now covers: same-conversation tokens (anonymous, XID,
standard-user) voting as themselves with no new participant and no new
token; cross-conversation tokens voting as a participant of their own in
the requested conversation, not the holder of the same pid number there and
not an orphan pid, with a token for that conversation in the response; a
cross-conversation token whose pid has no participant there; participationInit
with a cross-conversation token; and the OIDC-token and comment-route
controls. Votes are cast only through the vote route.

The existing "XID users should be able to participate in multiple
conversations" test voted with a token minted for a simulated uid that
exists in no users row; it now holds the token the server issued for a real
XID participant, as the flow it describes does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…lved

The JWT extractor flagged a token as being for another conversation by
comparing its conversation id with the request's query string first and
the body second, while POST /votes resolves the conversation from the body
alone. A token issued in X, presented with X in the query string and Y in
the body, was therefore not flagged, kept X's pid, and the vote was
recorded in Y under that pid. The extractor now always records the token's
conversation id, and ensureParticipant decides the token's scope against
the zid the route resolved: the same conversation id string as the one the
route's parameter parsing read is the same conversation and costs nothing;
otherwise the token's conversation id is resolved (an LRU-cached read) and
the zids are compared. Every genuine mismatch now also requests a
replacement token, so a response carries this conversation's token even
when the participant already existed (the XID case that keeps its user).

In a conversation that requires an invite, an identity without a pid for
this conversation (a token issued elsewhere, or no participant token) had
its first-join refusal applied before its existing membership was looked
up, so an existing participant presenting a token from another
conversation was refused. The membership is now looked up by (zid, uid)
first, and the pid found is passed on so nothing is read twice; a newcomer
without an invite is still refused and the owner still joins.

The integration suite adds: a same-conversation vote with another
conversation's id in the query string; the query/body split for all three
token kinds, with the pid held by someone in the other conversation and
with it absent; presenting the same token a second time; and the
invite-only conversation with an existing participant, a newcomer without
an invite and the owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…ke the token's pid number

When nobody holds the token's pid number in the other conversation, the
participant created there for the caller is allocated the next number,
which can be the same one; the assertion for that case now checks that the
vote's participant row exists and was created for the vote, not that its
number differs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
@github-actions

Copy link
Copy Markdown

Delphi Coverage Report

File Stmts Miss Cover
polismath/init.py 2 0 100%
polismath/benchmarks/bench_pca.py 128 107 16%
polismath/benchmarks/bench_repness.py 81 65 20%
polismath/benchmarks/bench_update_votes.py 38 28 26%
polismath/benchmarks/benchmark_utils.py 34 0 100%
polismath/components/init.py 1 0 100%
polismath/components/config.py 165 70 58%
polismath/conversation/init.py 2 0 100%
polismath/conversation/conversation.py 1291 264 80%
polismath/conversation/group_vote_counts.py 38 2 95%
polismath/conversation/manager.py 131 42 68%
polismath/database/init.py 1 0 100%
polismath/database/dynamodb.py 392 176 55%
polismath/database/postgres.py 414 95 77%
polismath/delphi_storage/init.py 1 0 100%
polismath/delphi_storage/codec.py 295 22 93%
polismath/delphi_storage/embedding_spool.py 58 6 90%
polismath/delphi_storage/golden_corpus.py 60 9 85%
polismath/delphi_storage/import_report.py 51 3 94%
polismath/delphi_storage/legacy_import.py 171 16 91%
polismath/delphi_storage/postgres.py 49 22 55%
polismath/delphi_storage/production_import.py 547 120 78%
polismath/delphi_storage/resource.py 179 32 82%
polismath/delphi_storage/scan_pacing.py 77 0 100%
polismath/delphi_storage/writer.py 193 18 91%
polismath/empty_output.py 29 2 93%
polismath/engine_adapter.py 317 265 16%
polismath/job_child/init.py 356 52 85%
polismath/job_child/census.py 101 8 92%
polismath/pca_kmeans_rep/init.py 5 0 100%
polismath/pca_kmeans_rep/clusters.py 257 38 85%
polismath/pca_kmeans_rep/corr.py 98 17 83%
polismath/pca_kmeans_rep/group_k_smoother.py 28 0 100%
polismath/pca_kmeans_rep/legacy_kmeans.py 197 8 96%
polismath/pca_kmeans_rep/pca.py 162 19 88%
polismath/pca_kmeans_rep/repness.py 226 5 98%
polismath/poller/init.py 4 0 100%
polismath/poller/admission.py 318 15 95%
polismath/poller/backfill.py 936 32 97%
polismath/poller/capacity.py 485 27 94%
polismath/poller/capacity_queue.py 248 13 95%
polismath/poller/coordinator_bridge.py 317 317 0%
polismath/poller/math_writer.py 113 3 97%
polismath/poller/promotion.py 112 4 96%
polismath/poller/readiness.py 368 13 96%
polismath/poller/rebuild_child.py 133 5 96%
polismath/poller/service.py 665 52 92%
polismath/poller/worker_pool.py 140 0 100%
polismath/queue/executor.py 223 111 50%
polismath/regression/init.py 4 0 100%
polismath/regression/clojure_comparer.py 188 20 89%
polismath/regression/comparer.py 891 640 28%
polismath/regression/datasets.py 135 27 80%
polismath/regression/recorder.py 36 27 25%
polismath/regression/utils.py 130 85 35%
polismath/replay/init.py 2 0 100%
polismath/replay/attribution_capture.py 49 0 100%
polismath/replay/axis_continuity.py 347 24 93%
polismath/replay/certify.py 999 59 94%
polismath/replay/crosslang.py 128 2 98%
polismath/replay/decision_ties.py 55 8 85%
polismath/replay/diagnostics.py 50 7 86%
polismath/replay/driver.py 73 1 99%
polismath/replay/dsn_admission.py 36 1 97%
polismath/replay/equiv_query.py 10 0 100%
polismath/replay/event_ingress.py 95 1 99%
polismath/replay/fixture_bundle.py 815 62 92%
polismath/replay/fixture_config.py 266 11 96%
polismath/replay/fixture_extract.py 285 12 96%
polismath/replay/fixture_generate.py 103 1 99%
polismath/replay/fixture_samples.py 157 14 91%
polismath/replay/fixture_selection.py 119 0 100%
polismath/replay/fixture_survey.py 147 11 93%
polismath/replay/legacy_pca.py 29 17 41%
polismath/replay/polarity.py 319 25 92%
polismath/replay/poller_equiv.py 776 99 87%
polismath/replay/prodclone.py 193 2 99%
polismath/replay/real_data.py 146 10 93%
polismath/replay/schedule.py 205 10 95%
polismath/replay/served_math.py 159 4 97%
polismath/replay/shard_bench.py 189 65 66%
polismath/replay/stagecompare.py 890 161 82%
polismath/replay/stages.py 256 40 84%
polismath/replay/stepcompare.py 86 1 99%
polismath/replay/store.py 120 11 91%
polismath/replay/tie_capture.py 107 19 82%
polismath/replay/types.py 71 6 92%
polismath/run_math_pipeline.py 284 93 67%
polismath/tools/engine_rebuild.py 230 11 95%
polismath/types.py 22 0 100%
polismath/utils/cli_flags.py 15 0 100%
polismath/utils/clj_hash.py 44 0 100%
polismath/utils/env_flags.py 13 0 100%
polismath/utils/general.py 63 39 38%
polismath/utils/output_profile.py 66 2 97%
polismath/utils/serialization.py 10 5 50%
polismath/utils/vote_convention.py 159 5 97%
umap_narrative/501_calculate_comment_extremity.py 113 38 66%
umap_narrative/502_calculate_priorities.py 136 103 24%
umap_narrative/700_datamapplot_for_layer.py 507 507 0%
umap_narrative/701_static_datamapplot_for_layer.py 310 310 0%
umap_narrative/702_consensus_divisive_datamapplot.py 435 275 37%
umap_narrative/801_narrative_report_batch.py 666 455 32%
umap_narrative/803_check_batch_status.py 268 148 45%
umap_narrative/llm_factory_constructor/init.py 2 0 100%
umap_narrative/llm_factory_constructor/model_provider.py 234 107 54%
umap_narrative/narrative_data.py 178 65 63%
umap_narrative/numerical_stages.py 54 25 54%
umap_narrative/polismath_commentgraph/init.py 1 0 100%
umap_narrative/polismath_commentgraph/cli.py 268 268 0%
umap_narrative/polismath_commentgraph/core/init.py 3 3 0%
umap_narrative/polismath_commentgraph/core/clustering.py 108 108 0%
umap_narrative/polismath_commentgraph/core/embedding.py 104 104 0%
umap_narrative/polismath_commentgraph/lambda_handler.py 219 219 0%
umap_narrative/polismath_commentgraph/schemas/init.py 2 0 100%
umap_narrative/polismath_commentgraph/schemas/dynamo_models.py 160 9 94%
umap_narrative/polismath_commentgraph/tests/conftest.py 17 17 0%
umap_narrative/polismath_commentgraph/tests/test_clustering.py 74 74 0%
umap_narrative/polismath_commentgraph/tests/test_embedding.py 55 55 0%
umap_narrative/polismath_commentgraph/tests/test_storage.py 87 87 0%
umap_narrative/polismath_commentgraph/utils/init.py 9 7 22%
umap_narrative/polismath_commentgraph/utils/converter.py 283 237 16%
umap_narrative/polismath_commentgraph/utils/group_data.py 332 289 13%
umap_narrative/polismath_commentgraph/utils/storage.py 609 489 20%
umap_narrative/reset_conversation.py 162 53 67%
umap_narrative/run_pipeline.py 342 212 38%
umap_narrative/topic_naming.py 148 11 93%
Total 25695 8016 69%

@colinmegill
colinmegill merged commit 50269f7 into edge Oct 13, 2026
28 of 29 checks passed
@colinmegill
colinmegill deleted the vote-misattribution-fix branch October 13, 2026 16:49
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