Engineering handoff

Polls v1 for the Raklet social wall

A new "Poll" post type with anonymous-by-default voting, sized to ship in a week and built to extend toward informal-election features as customers ask.

v1 minimum (recut) 3 stacked PRs · 1 deploy branch All 6 projects build clean Anonymity test in place

01TL;DR

Polls is the smallest possible "structured input" feature we can ship: a new post type on the wall, anonymous-by-default, single or multi-choice, server-enforced close. Stacked across three PRs (schema, backend, UI). Everything builds clean. A single deploy branch (polls-v1-deploy) is published so the team can check out one branch and test the full feature locally.

Before any code deploys: three customer-validation calls (The Assignment) decide whether v1 stays small or v1.5 election-grade features pull in. The design doc carries a full v1.5 / v2 roadmap captured during 5 review passes.

02What we're building

A "Poll" post type that sits inside the existing Announcement system on the community wall. Admins create a poll the same way they create an image-or-text post today, with a new toggle at the top of the create form. Members see the poll in their feed and vote with one tap. Results render inline.

Two important per-poll settings the admin picks at create time:

SettingOptionsWhat changes
PollVoteVisibility Anonymous (default) · Public Whether vote attribution is visible. Anonymous polls promise no admin can see who voted what. Public polls show member names next to their vote.
PollResultsVisibility AfterVoting (default) · AfterClose · Always When results become visible to the audience. Per-row counts hide cleanly when not allowed.
PollSelectionMode Single · Multi Radios vs checkboxes. Multi requires a max-selections cap (2–10).
PollClosesOn 1h to 90d from publish Server-enforced via WebJob. Votes after close are rejected at the API.

The differentiated framing is that v1's defaults (lock-after-vote, anonymous, eligibility = audience) are safe enough for non-binding informal elections in associations and non-profits — the customer set Raklet actually serves. v1.5 adds the formal-election features (eligibility scoping, voter-roll snapshot, k-anonymity warnings, audit log) when customer demand validates that framing.

The member poll card — three states

Members see the same card layout regardless of where they are in the flow. Card morphs between states without a page reload. The trust microcopy is conditional on the per-poll PollVoteVisibility setting.

1. Not voted (Anonymous)
Sarah Chen · Org Admin · 2h ago
Where should we host the 2026 annual gala?
  • Hotel A — Marriott Downtown
  • Hotel B — Hyatt Riverside
  • Hotel C — Ramada Center
Raklet does not show individual votes to anyone, including admins.
Closes in 3 days·47 voted
2. Voted (results visible)
Sarah Chen · Org Admin · 2h ago
Where should we host the 2026 annual gala?
  • Hotel A — Marriott DowntownYour vote47%
  • Hotel B — Hyatt Riverside38%
  • Hotel C — Ramada Center15%
Closes in 3 days·48 voted
3. Closed (final results)
Poll closed
Sarah Chen · Org Admin · 5d ago
Where should we host the 2026 annual gala?
  • Hotel A — Marriott DowntownYour vote52%
  • Hotel B — Hyatt Riverside33%
  • Hotel C — Ramada Center15%
Closed on May 24·83 voted

Public mode — vote attribution visible

The same card with PollVoteVisibility = Public. Notice the microcopy changes — Raklet is upfront with members that their name will appear next to their vote when they cast it.

Public mode (microcopy differs)
Marcus Lee · Org Admin · 1h ago
Who's bringing dessert to the potluck?
  • I'm bringing dessert
  • I'm bringing something savory
  • I'll just show up
Your name will be shown next to your vote.
Closes in 5 days·12 voted

Admin create-poll form

The admin form lives in the existing create-post modal. A segmented Post / Poll toggle at the top swaps the editor in place. No new admin nav, no separate page.

Post Poll
Cancel

End-to-end flow (admin to member to closed)

Admin creates
Toggle to Poll in the create modal. Fill question, options, close date, visibility settings. Publish.
Poll lands on wall
Renders as first item in the feed. Same place as image posts today.
Members vote
One tap. JS posts to /v2/.../polls/{id}/vote with Idempotency-Key header.
Results render
Card morphs to results state. Bar chart inline. "Your vote" badge on chosen option.
WebJob closes
At PollClosesOn, WebJob flips status. Card shows "Poll closed" pill + final results.

03How we got here

The original CEO ask was simple: "add a polls post type with a results-visibility setting and anonymity." Over the course of five review passes, the design accumulated election-grade features (eligibility scoping, k-anonymity warnings, audit log, member-created polls + moderation queue, the EntityLeakageGuard reflection assertion, 3-step migration trilogy, email-results rendering, etc.). At the end of that process the CEO observed that v1 had become roughly 3× the original ask without customer validation.

The plan was recut to minimum-v1. Everything the reviews surfaced is preserved as a v1.5 / v2 roadmap inside the design doc, but ships only when real customer demand surfaces it. This presentation reflects the post-recut state.

Lesson worth keeping

Each review's "defensible-in-isolation" addition compounds into "over-engineered-in-aggregate." Customer validation belongs before the maximalist review chain, not after. Saved as a project feedback memory so future feature plans don't repeat this.

04V1 scope

In v1

  • New "Poll" post type via AnnouncementType discriminator on Announcement
  • 3 new tables: PollOption, PollVote, IdempotencyKey
  • 7 nullable columns on Announcement
  • Single or Multi choice (admin picks per poll)
  • Anonymous (default) or Public vote visibility
  • Results visibility timing (after-vote / after-close / always)
  • Lock-after-vote (no vote change in v1)
  • Server-enforced close via WebJob with optimistic concurrency
  • Idempotency-Key header on vote submission (RFC 8594 style)
  • Org-level SocialAccess claim gates the endpoints
  • Member poll card with not-voted / voted / closed states
  • Admin create-poll form (segmented Post/Poll toggle)
  • Conditional trust microcopy on the poll card based on Vote Visibility
  • Anonymity guard integration test

Deferred (v1.5 / v2)

  • Eligibility scoping beyond the existing audience picker
  • K-anonymity warning UI for small electorates
  • Audit log table
  • Voter-roll snapshot
  • "Other (specify)" free-text option
  • Vote change after first cast
  • Member-created polls + moderation queue
  • Publish-confirmation modal
  • Email-results inline rendering
  • Quorum, ballot certification, vote receipts
  • Cryptographic anonymity (ephemeral-salt design)
  • CSV result export
  • Multi-ballot (Strawpoll-style)
  • Events ↔ Polls integration ("vote on the event date")

05What's deferred and why

Most deferrals are election-grade features (eligibility scoping, voter-roll snapshot, audit log, k-anonymity warning) that the design reviews surfaced as worthwhile once customers validate the governance use case. v1 ships safe enough for informal elections (lock-after-vote, server-enforced close, anonymity-by-policy with the integration test) without yet committing the architecture to formal governance.

A second group of deferrals is UX polish (publish-confirmation modal, drag-and-drop options editor, email-results rendering) — defensible additions that don't block the core flow. They join the roadmap.

The third group is platform infrastructure (EntityLeakageGuard reflection assertion, EligibilityRollSnapshot generalized for Events) that only earns its complexity once Raklet has multiple consumers. IdempotencyKey is the one platform primitive we kept in v1, because naming it correctly is a one-way decision — refactoring after Events RSVP and Payments adopt it is expensive, doing it right at first commit is cheap.

06Architecture

Approach A from the design doc: a discriminator on the existing Announcement table. Survived four reviews and one Codex challenge.

┌───────────────────────────────────────────────────────────────────┐ │ Announcement (existing entity, MODIFIED) │ │ + AnnouncementType enum (Standard | Poll) ── discriminator │ │ + 6 nullable poll-specific columns │ │ ↳ all existing rows are Standard; metadata-only migration │ ├───────────────────────────────────────────────────────────────────┤ │ NEW PollOption ── FK ─→ Announcement │ │ NEW PollVote ── FK ─→ Announcement + PollOption + Member │ │ UNIQUE (AnnouncementId, MemberId, OptionId) │ │ ↑ enforces lock-after-vote at the DB layer │ ├───────────────────────────────────────────────────────────────────┤ │ NEW IdempotencyKey (GENERIC, not poll-specific) │ │ OperationScope-namespaced │ │ Polls = first consumer; Events RSVP next │ └───────────────────────────────────────────────────────────────────┘ ↓ PollService (Services/) IPollService CreatePoll SubmitVote ← hot path; idempotent GetResults ← visibility matrix ClosePoll ← optimistic UPDATE CloseExpiredPolls ↓ ┌────────────────────────┬────────────────────────┐ ↓ ↓ ↓ V2 API endpoints PollFunctions (WebJobs) Razor + JS (Application) (Raklet.Api) queue-triggered _PollCard.cshtml POST /polls close-expired-polls polls-vote.js POST /vote cleanup-idempotency CreateOrUpdatePost (admin) GET /results polls-admin-create.js POST /close

The hot path is PollService.SubmitVoteAsync. Read order: idempotency-key replay check, poll-state validation, audience + eligibility check, lock-after-vote check, transactional insert. The DB unique constraint catches concurrent racers; the service maps the constraint violation back to "you already voted" with the caller's current vote.

Component view of the vote-submission hot path

Client API Service Data Member browser _PollCard.cshtml + polls-vote.js Admin browser CreateOrUpdatePost + admin JS POST /vote V2SocialController · Idempotency-Key POST /polls V2SocialController · CreatePoll PollService.SubmitVoteAsync (hot path) idempotency replay → validate → lock check → transactional insert → unique-constraint race handling Announcement + 7 nullable poll cols PollOption 2–10 per poll PollVote UNIQUE(ann, member, opt) IdempotencyKey generic, 24h TTL fetch + Idempotency-Key JSON POST

Blue path = vote submission (the hot path). Grey = standard reads + create. Dashed boxes = SQL tables. IdempotencyKey stores the per-request hash so a network-retried vote returns the cached response instead of double-counting.

07The anonymity promise

PollVote.OrganisationMembershipId exists in the database (we need it to enforce lock-after-vote and to surface the caller's own vote). The promise is that no V2 endpoint, no admin response, no export ever surfaces the link between a member and their vote choice on Anonymous polls. Pseudonymous-in-DB, anonymous at every wire.

Three layers of protection in v1:

  1. Service-layer scopingGetResultsAsync only returns the calling member's own MyVoteOptionIds; other members' votes are never serialized.
  2. Razor DTO surfacePollPayloadDto carries aggregates only. Per-option voter names are deferred to v1.5 for Public-mode polls; v1's Public-mode distinction is microcopy only.
  3. Integration testRaklet.UnitTests/Polls/PollAnonymityTest.cs has three sub-tests:
    • Reflection DTO whitelist — fails fast if any future refactor adds OrganisationMembershipId, MemberId, Member, or Voter to a poll-vote-adjacent DTO.
    • Live PollService exercise — seeds an anonymous poll plus three voters, asserts per-caller MyVote isolation, serializes results to JSON and asserts the forbidden property names do not appear.
    • V2 HTTP test skeleton[Ignore]'d placeholder for OwinTestServer wiring.
Promise as displayed to members

On Anonymous polls, the card shows: "Raklet does not show individual votes to anyone, including admins." On Public polls it shows: "Your name will be shown next to your vote." The promise is conditional and accurate.

What the database stores vs what the API returns

For an Anonymous poll where Sarah, Marcus, and Jenny voted, here's what lives in PollVote vs what any caller sees on GET /v2/.../social/announcements:

Database — dbo.PollVote
Id        AnnId     OrgMembershipId  OptionId
─────────────────────────────────────
guid-1    poll-A    sarah-id         opt-A
guid-2    poll-A    marcus-id        opt-A
guid-3    poll-A    jenny-id         opt-B
Stripped at
serialization
API response — what callers see
{
  "id": "poll-A",
  "poll": {
    "totalVotes": 3,
    "options": [
      { "id": "opt-A", "voteCount": 2 },
      { "id": "opt-B", "voteCount": 1 }
    ],
    "myVote": { "optionIds": ["opt-A"] }
  }
}

OrganisationMembershipId exists in the DB row (we need it to enforce lock-after-vote and to surface the caller's own vote at myVote.optionIds) but is never serialized adjacent to vote data on any V2 endpoint when the poll is Anonymous. PollAnonymityTest asserts this via reflection (DTO whitelist) + live JSON serialization grep.

08PRs and deploy branch

Stacked review surfaces, in merge order:

#13817 OPEN builds clean
feat(models): polls v1 schema + entity model
Branch: feature/poll-migration · Base: master · Stage: PR-1 (sealed scope)
Adds 7 nullable columns to Announcement, creates 3 new tables (PollOption, PollVote, IdempotencyKey) plus the supporting enums, EF6 Fluent config, and the migration files. Metadata-only ALTER on Announcement — no table rewrite.
#13818 OPEN builds clean
feat(polls): backend service + V2 API + WebJobs
Branch: feature/poll-backend · Base: feature/poll-migration · Stage: PR-2 (sealed scope)
PollService with the four core methods, V2 controller endpoints (Create / Vote / Results / Close) with Idempotency-Key header handling and SHA256 operation-hash replay detection, queue-triggered WebJobs for poll close + idempotency-key janitor.
#13819 DRAFT builds clean
feat(polls): member poll card + vote JS + styling + 5 follow-ups
Branch: feature/poll-ui · Base: feature/poll-backend · Stage: PR-3 (11 commits — baseline + 5 follow-up merges)
Member-facing poll card with three states, vote-submission JS with idempotency-key generation, admin create-poll form, DTO wiring through to AnnouncementsList, DI registration, and the anonymity integration test. Stays DRAFT until local QA passes.
#13831 DO NOT MERGE QA-only
Polls v1 deploy branch (all 3 PRs combined for local testing)
Branch: polls-v1-deploy · Base: master
Single branch the team can check out and deploy locally without having to apply the three stacked PRs separately. Convenience snapshot. Delete post-launch.

09How to test locally

git fetch origin
git checkout polls-v1-deploy
git pull origin polls-v1-deploy

# Restore packages once if your main checkout hasn't recently
.\.nuget\NuGet.exe restore Raklet.sln

# Apply migration (Visual Studio Package Manager Console, Models project)
Update-Database -ConfigurationTypeName Models.Migrations.Configuration

# Build + run via your normal local flow
# (build-fast.ps1 for Application; MSBuild per-project for Models,
#  Services, Raklet.Api, Raklet.WebJobs, Raklet.UnitTests)

Then walk this script on gercek.raklet.org:

  1. Admin: Open the community wall. Click new post. Toggle to Poll. Fill question, 2–4 options, close date 1h+ out. Pick selection mode + results visibility + vote visibility. Publish.
  2. Verify it lands on the wall as the first item.
  3. Member: open the wall as a different member. The card should render with options as radios (or checkboxes for Multi). Microcopy below the options matches the vote-visibility you picked (Anonymous: "Raklet does not show…"; Public: "Your name will be shown…").
  4. Vote. Pick an option. Vote button enables. Click. State morphs to results — inline bar chart with percentages, "Your vote" badge on the chosen option.
  5. Try to vote again after refresh. Expect 409 with a friendly "You have already voted" message. (Lock-after-vote is v1 behaviour.)
  6. Close the poll. Wait for the WebJob (every ~5 min if scheduled) or call POST /v2/.../social/polls/{id}/close manually. Card flips to closed state with a "Poll closed" pill plus final results.
  7. Anonymity sanity check. Open DevTools network tab during the wall load. The GET /v2/.../social/announcements response on an Anonymous poll should not contain OrganisationMembershipId anywhere adjacent to vote or option data. Your own myVote.optionIds is the only member-link.

10The Assignment (blocking PR-1 deploy)

Before any code deploys to production

Three customer-validation calls. Pick three association or non-profit customers. Ask each one a single question: "If we shipped polls in your wall, what's the first thing you'd run a poll on?" 30 minutes of customer success time, total.

The answers route the v1.5 roadmap:

30 minutes of calls prevents 2+ weeks of building the wrong default.

11V1.5 / v2 roadmap

The full roadmap with rationale, effort estimates, and dependencies lives in docs/features/polls/design.md under "V1.5 / V2 Roadmap." Headline items in rough priority order if customers validate governance use:

ItemPulls in if customers ask for…
Eligibility scoping (PollEligibility table + admin picker)"Only voting members can vote"
Voter-roll snapshot at publish (EligibilityRollSnapshot)"X of Y eligible voted" denominator
K-anonymity warning UI for <10 electoratesSmall-board / committee polls
Audit log (scrubbed of PollOptionId on vote events for timing-attack mitigation)Governance / compliance trail
Settings-lock-at-publish (currently v1 locks at first vote)Election-grade manipulation prevention
Notification-to-all-eligible (currently notifies creator only)So notification existence doesn't reveal participation

If casual polls dominate the customer calls, the UX polish items pull in first instead:

"Other (specify)" + PollOtherResponse table"What should we bring to the potluck?"
Vote change (Mode B) with soft-delete + UPDLOCKCasual polls where members want to revise
Publish-confirmation modalFriction on sensitive polls
Email-results inline renderingThe strongest viral surface of the feature
Events ↔ Polls integration ("vote on the date")Strongest "why Raklet" governance story

12Process record

What ran before any code was written. Useful context when reviewing the PRs — most decisions have a documented "why" behind them in the design doc.

/office-hours — design session
9 premises locked, Approach A architecture chosen. 13 adversarial issues resolved across 2 review rounds. Final 9/10.
/plan-eng-review — architecture pass
10 issues across 4 sections (scope, architecture, code quality, performance), all resolved. EF6 syntax corrections, migration safety pattern, optimistic concurrency on close, idempotency design.
Codex outside-voice challenge
20 findings. 8 originally absorbed; most later moved to v1.5 roadmap during the scope recut.
/plan-design-review — UI/UX pass
Design score 3.5/10 → 9/10. 11 decisions including the user-corrected per-poll PollVoteVisibility setting.
/plan-ceo-review → scope recut
Selective expansion mode initially; flipped to scope reduction when the CEO observed v1 had become ~3× the original ask. Recut to minimum-v1; the accumulated thinking is preserved as the v1.5/v2 roadmap.
Implementation
PR-1 + PR-2 + PR-3 baseline shipped in one focused session. Five follow-up agents ran in parallel for DTO wiring, admin form, wireup audit, DI registration, and the anonymity test. All builds clean, all merged into feature/poll-ui / polls-v1-deploy.

13Standing rules to follow

Project conventions from the Raklet team that the implementation already honours and v1.5 work should keep honouring: