Files
govoplan-poll/README.md

9.5 KiB

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:

  • draftopen or archived
  • opendraft, closed, or archived
  • closedopen, decided, or archived
  • decidedopen, 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 decideddecided 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

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