# GovOPlaN Poll **Repository type:** module (domain). `govoplan-poll` owns lightweight decision and availability polls: date polls, option polls, yes/no decisions, single-choice, multi-choice, ranked choice, response visibility rules, anonymity settings, closing rules, and result summaries. Poll is intentionally small and reusable. It provides polling primitives for modules such as Scheduling, Campaign, Portal, Calendar, and Evaluation without owning their domain workflows. ## Boundaries Poll owns: - poll definitions, options, invitations, responses, and result summaries - availability matrices for scheduling use cases - anonymity and result visibility semantics - closing, reopening, and decision outcome metadata Poll does not own: - meeting scheduling workflows; those belong in `govoplan-scheduling` - post-event surveys, scoring, rubrics, and analytics; those belong in `govoplan-evaluation` - generic form rendering and submission runtime; those belong in `govoplan-forms` and `govoplan-forms-runtime` - mail, notification, and portal delivery infrastructure ## Optional Access Integration `govoplan-access` is optional. With Access installed, Poll can use identity resolution, permission checks, and role templates. Without Access, Poll remains usable for reduced flows such as anonymous participation, signed public links, or participant lists supplied by another adapter. ## Current Backend Slice The first backend implementation adds poll storage, option storage, response storage, validation, result aggregation, tenant summaries, module migrations, and authenticated API routes for: - single-choice, multiple-choice, yes/no, ranked-choice, and availability polls - yes/no/maybe polls for tentative decisions - opening, closing, and deciding polls - submitting and updating respondent answers - listing responses and reading result summaries - signed participation links for reduced/no-Access participation - context and workflow metadata for modules such as Scheduling ## Governed signed participation The v0.1.10 contract lets a consuming module bind an invitation to an exact response gateway (`module_id`, `resource_type`, and `resource_id`) and snapshot generic participation rules onto it. A bound invitation cannot use the legacy `GET /poll/public/{token}` or `POST /poll/public/{token}/responses` routes: both return the same generic not-found response used for invalid, expired, and revoked links. There is deliberately no browser-facing Poll bypass when the owning gateway is missing. That boundary is Poll-wide. Setting `context_module` declares a module-owned Poll; for a standalone Poll, its first governed invitation opts the whole Poll into governed participation. From then on, direct authenticated response writes, ordinary invitation creation, and every legacy public link fail closed, including links created before governance was enabled. Governed invitations can only be created through the in-process participation capability and their gateway must match the Poll's declared module resource (or the standalone Poll's first gateway). Polls that never opt into governance retain their ordinary authenticated and signed-link behavior. Module ownership also protects management boundaries. Generic Poll update, lifecycle, option, and invitation-revocation paths cannot mutate an owned Poll, and generic raw-response and invitation projections cannot bypass the owning module's privacy policy. The in-process capability supplies the exact module, resource type, and resource id again for every mutation; Poll compares that owner while holding its row lock. Aggregate result summaries remain reusable. Consumers resolve and submit bound invitations through the `poll.participation_gateway` in-process capability. The policy covers: - at most one non-`unavailable` selection - whether `maybe` is accepted - a maximum number of definitive participants per option - response comments (trimmed and limited to 4,000 characters) - email for anonymous participants - an anonymous-password verification requirement For availability polls, `available` reserves capacity while `maybe` does not. Poll re-enforces these rules under its Poll-row lock, so concurrent final-place claims serialize on PostgreSQL. A gateway-owned password never crosses the capability boundary: the owning module stores the sole salted verifier, throttles attempts before checking it, and supplies only the `anonymous_password` verification attestation. Poll rejects secret-like invitation/response metadata and stores no password column. Authenticated module flows do not need to retain a public bearer token. They can resolve and submit by exact tenant, Poll, governed invitation, gateway, and respondent identifiers. Poll rejects mismatched identities and applies the same Poll-row lock and policy checks used by the public-token path. A consumer can lazily create that governed invitation, retain only its id, and discard the raw token when no public link is needed. An owning gateway can update a non-revoked invitation's expiry in place. A past timestamp expires the existing link immediately; a later future timestamp or `null` reactivates that same link without exposing or rotating its bearer token. Revoked invitations remain revoked, and exact retries are idempotent. The capability also supports durable invitation-scoped idempotency keys, response prefill, option addition/removal, and invitation revocation. Exact submission retries return the existing response; key reuse with different content is rejected. Because a response is an editable resource, replay returns its current representation rather than an immutable snapshot of the first submission. Option removal and option changes invalidate only answers bound to that option, and repeated removal/revocation is an idempotent replay even after the Poll has moved out of an editable lifecycle state. ## Identified response invariant Poll stores at most one active response for each identified respondent in a Poll. PostgreSQL and SQLite enforce this with the partial unique index `uq_poll_responses_active_respondent`; anonymous and tombstoned responses do not participate in that invariant. If two submissions race, the losing insert is rolled back to a savepoint and follows the ordinary update policy against the winning row. Unrelated integrity errors are not converted into response updates. The migration deterministically retains the latest active row by `submitted_at DESC, id DESC` and tombstones older duplicates. Apply it while Poll response writes are quiesced or while the platform maintenance lock is held. The released migration-head baseline must only be advanced as part of the reviewed release that includes this migration; it is not a development head ledger. ## Scheduling As A Poll-Backed Workflow Scheduling should use Poll as the reusable response collection primitive, not reimplement availability polling. A scheduling flow is modeled as an `availability` poll with `context_module="scheduling"`, a scheduling-owned context resource id, and workflow steps such as collect availability, rank candidate slots, decide, notify participants, and hand off to Calendar or Appointments. Poll stores the shared poll/options/responses/result summary. Scheduling owns the domain workflow around rooms, calendars, free/busy checks, reminders, appointment creation, and optional Workflow integration. ## Poll lifecycle Poll configures its lifecycle through the separate, pure transition engine in `backend/transitions.py`. The default policy is: - `draft` → `open` or `archived` - `open` → `draft`, `closed`, or `archived` - `closed` → `open`, `decided`, or `archived` - `decided` → `open`, `decided` (an explicit re-decision), or `archived` - `archived` → the status from which the Poll was archived Reopening and archiving preserve responses, close history, and decision metadata. Every applied transition has a Poll-owned lifecycle audit record. Re-deciding always appends a record and supersedes the current decision. An exact retry carrying the same `Idempotency-Key` is a no-op and returns the original transition record; reusing that key for a different action or option is rejected. Without an idempotency identity, a repeated transition is invalid except for the deliberately auditable `decided` → `decided` action. Poll API representations expose every lifecycle action with its availability and, when unavailable, the policy reason. Management clients can use the `GET /poll/polls/{poll_id}/transitions` endpoint to read the durable history instead of duplicating the matrix in their UI. Polls created through the v0.1.9 contract start in `draft` or `open`; later states are entered through the transition engine. Older archived rows may not contain the status from which they were archived. Their prior state cannot be inferred safely, so unarchive restores them to the least-permissive `draft` state and marks that legacy fallback explicitly in the audit metadata. ## Development Install ```bash cd /mnt/DATA/git/govoplan-core ./.venv/bin/python -m pip install -e ../govoplan-poll ``` Focused manifest verification: ```bash cd /mnt/DATA/git/govoplan-poll PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests ``` Run the optional two-session PostgreSQL race check against a disposable or development database account that may create schemas: ```bash GOVOPLAN_POLL_TEST_POSTGRES_URL=postgresql+psycopg://user@localhost/database \ /mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q tests/test_response_uniqueness.py ```