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-formsandgovoplan-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-
unavailableselection - whether
maybeis 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.
Owning modules can also retire a participant's responses through the optional response-retirement extension. Retirement is idempotent and soft-deletes the live rows so aggregation and capacity checks stop counting them, while answers and a reason/source retirement record remain available for audit. The boundary accepts only server-trusted respondent or invitation identities and rejects secret-like metadata.
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→openorarchivedopen→draft,closed, orarchivedclosed→open,decided, orarchiveddecided→open,decided(an explicit re-decision), orarchivedarchived→ 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. A command whose requested lifecycle state already holds is also
an idempotent domain no-op, even without an idempotency identity. It returns
the current Poll with replayed=true and transition=null, does not append a
lifecycle audit record, and is emitted only to operational logs as duplicate
command telemetry. A new idempotency key supplied for such an audit-free no-op
is not consumed. Re-deciding with a different option remains a new auditable
action; repeating the current decision is a no-op.
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
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-poll
Focused manifest verification:
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:
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