207 lines
9.9 KiB
Markdown
207 lines
9.9 KiB
Markdown
# GovOPlaN Poll
|
|
|
|
<!-- govoplan-repository-type:start -->
|
|
**Repository type:** module (domain).
|
|
<!-- govoplan-repository-type:end -->
|
|
|
|
`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.
|
|
|
|
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` → `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
|
|
```
|