Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODING_CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ file.
- **Readers go through `require`; writers go through `requireForUpdate`**, which takes a row lock held until the transaction commits. That pairing replaced a `synchronized` on every method: a monitor inside a transactional proxy is released before the commit, so it reads like a guarantee and is not one.
- `spring.jpa.open-in-view=false`. A screen holds a record and never an entity, so a lazy read outside a transaction is a bug, and this is what makes it fail loudly instead of hiding until the next thing moves.
- A path-like setting names the directory, not the whole URL: `WHICHDAY_DATA_DIR`, so moving the database file cannot drop `MODE=PostgreSQL` on the way past.
- **Nothing is stored forever.** Two retention windows delete a poll — a few days after it ends, and unconditionally at its maximum age — and `docs/REQUIREMENTS.md` §9 is the record. A new table that hangs off `poll` needs `on delete cascade` or the sweep leaves its rows behind; one that does not needs a rule of its own, because the only reason the sweep reaches anything is that a poll is the root of it.

## 10a. Security headers

Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ It is one container with its database inside it, and nothing else to bring up.
you want it.
- **Run one container, not two.** The database is a file, and only the process holding
it can open it — a second container on the same volume will not start.
- **Polls do not stay forever.** A poll is deleted five days after it ends
(`WHICHDAY_RETENTION_AFTER_POLL_ENDS`), and no poll survives 90 days from the day it
was made (`WHICHDAY_RETENTION_DAYS`) — whatever state it is in, a draft nobody sent
and a poll still collecting answers included. Deleting a poll deletes the answers with
it and cannot be undone, so a saved link then reads as a link to a poll that never
existed. Both are whole days, and either is `never` to switch that window off.
`WHICHDAY_RETENTION_DAYS` also drops the names anonymous visitors typed, once no poll
refers to them any more.
- `WHICHDAY_ACCESS_MODE` is `anonymous` or `login`, and defaults to `anonymous`.
Anything else is a startup failure naming both.
- **`login` mode needs an OIDC client**: `WHICHDAY_OIDC_ISSUER_URI`,
Expand All @@ -74,6 +82,8 @@ services:
- WHICHDAY_OIDC_CLIENT_ID=change-me
- WHICHDAY_OIDC_CLIENT_SECRET=change-me
- FORWARD_HEADERS_STRATEGY=native
- WHICHDAY_RETENTION_AFTER_POLL_ENDS=5
- WHICHDAY_RETENTION_DAYS=90
restart: unless-stopped

volumes:
Expand All @@ -86,6 +96,8 @@ dropped — it is the default, and it configures nothing:
```yaml
environment:
- FORWARD_HEADERS_STRATEGY=native
- WHICHDAY_RETENTION_AFTER_POLL_ENDS=5
- WHICHDAY_RETENTION_DAYS=90
```

The left side of the mount is yours — the named volume above, or any host path. The
Expand Down Expand Up @@ -117,6 +129,8 @@ Environment=WHICHDAY_OIDC_ISSUER_URI=https://accounts.example.com
Environment=WHICHDAY_OIDC_CLIENT_ID=change-me
Environment=WHICHDAY_OIDC_CLIENT_SECRET=change-me
Environment=FORWARD_HEADERS_STRATEGY=native
Environment=WHICHDAY_RETENTION_AFTER_POLL_ENDS=5
Environment=WHICHDAY_RETENTION_DAYS=90
AutoUpdate=registry

[Service]
Expand All @@ -139,8 +153,8 @@ systemctl start whichday.service
`AutoUpdate=registry` is safe here because systemd stops the old container before
starting the new one, and only one process at a time may hold the database file.

Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` as
the only `Environment=` the unit needs.
Anonymous mode drops the same four lines here, leaving `FORWARD_HEADERS_STRATEGY` and
the two retention lines as the only `Environment=` the unit needs.

### Behind a proxy

Expand Down Expand Up @@ -219,6 +233,16 @@ A poll row names people by email address; the one place a name lives is the `acc
table, written when somebody signs in. So an invitee who signs up later shows their
real name on polls that predate their account.

Nothing stays forever. A sweep runs daily and deletes a poll once either retention
window has passed it — five days after it ended, or 90 days after it was created,
whichever comes first. It takes the ballots, the invitations, the candidate days and the
proposals with it.

The same 90 days drop the names typed into anonymous mode's who-are-you screen, once no
poll refers to them any more — so a visitor who typed a name and closed the tab leaves
nothing behind. An address a provider vouched for is never dropped: it belongs to somebody
who can sign in again and be recognised.

It is H2 rather than PostgreSQL on purpose:
[`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) says why.

Expand Down
92 changes: 87 additions & 5 deletions docs/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ authenticated. It is the one place a name lives — a poll stores nothing but ad
wherever a name belongs, including on other people's ballots. What the table holds in
this mode is a session's chosen name, and nothing reads it beyond rendering: the invitee
search is its only other reader and that screen is not part of this mode. The rows are
written when somebody says who they are rather than when they do anything, so they
accumulate with visitors and nothing removes them —
[`issues/0019-anonymous-names-accumulate-forever.md`](issues/0019-anonymous-names-accumulate-forever.md).
written when somebody says who they are rather than when they do anything, so a visitor
who typed a name and closed the tab leaves one behind. Those are swept: a minted name no
poll refers to any more is dropped at the same maximum age a poll has (§9).

**Identity does not outlive the session.** Close the tab and you are a new person. That
is the cost of having no accounts, and the admin code is what buys the organizer a way
Expand Down Expand Up @@ -496,6 +496,11 @@ writing method in `PollService` goes through `requireEditable`, which admits onl
`DRAFT` and `OPEN`; answers are refused separately by `requireOpen`, which says so in
the voter's own terms rather than the organizer's.

Final is not the same as permanent. A poll that has ended is deleted a few days later,
and every poll is deleted at its maximum age whatever state it is in — §9 records both
windows. So the last thing that happens to a poll is that it stops existing, and a link
to it then reads as a link to a poll that never did.

That means **the organizer has to settle on a day before the poll closes.** The default
closing date is the last day on the table, so the window is the whole life of the poll,
and a date locked in after the days have gone would be a decision about days nobody can
Expand Down Expand Up @@ -523,8 +528,9 @@ no way to act on it.
day, whether there are candidate days, the closing date and the clock. A stored column
would be wrong from the moment the clock crossed the closing date with nobody writing
to the row, and the thing that would fix that is a scheduled sweep
([issue 0005](issues/0005-closing-happens-on-read.md)). Adding the column later is a
migration and a backfill; removing one that lied for a month is not.
([issue 0005](issues/0005-closing-happens-on-read.md)) — which now has somewhere to live,
since retention brought one (§9). Adding the column later is a migration and a backfill;
removing one that lied for a month is not.

Seeding is the one path around the guard: `PollService.record` is package-private and
only `castVote` and the test fixtures reach it, to build polls that were decided before
Expand Down Expand Up @@ -674,6 +680,82 @@ instead of hiding from it.
against. Both versions are Boot's managed ones, so the pairing is Boot's rather than
ours.

### What is kept, and for how long

Nothing is kept forever. A sweep runs on a fixed delay and deletes polls that either of
two retention windows has passed, both set once by a deployment and both counted in whole
days like every other span here:

- **`WHICHDAY_RETENTION_AFTER_POLL_ENDS`, five days by default.** Measured from the day
the poll ended, and it reaches only the two final states, `CLOSED` and `LOCKED`.
- **`WHICHDAY_RETENTION_DAYS`, ninety days by default.** Measured from `created_at`, and
it reaches **every** poll there is — a draft nobody sent, a poll still collecting
answers, a settled one whose day has not come. Nothing survives it. It governs the
anonymous names below by the same number, since they are the other thing here that
accumulates on its own.

Either is `never` to switch that window off, which is what a deployment that wants to
keep everything sets. A window nobody can parse is a startup failure quoting the variable
and the value, for the same reason `WHICHDAY_ACCESS_MODE` is (§1): there is no safe
guess — one reading deletes what somebody meant to keep and the other keeps what they
meant to have gone.

**The second window exists because the first cannot reach everything.** A draft has no
date to have ended on, and an open poll's closing date is at most the last day on the
table, which can be months out. Without a rule measured from creation there are rows no
rule reaches, in a store that is one file nothing else prunes (§9). The consequence is
deliberate and worth naming: a poll created in September with candidate days in March is
deleted in December, while people are still answering it. The ceiling is a ceiling.

**A poll that has ended is dated by the later of its two dates** — its closing date, or
the day locked in. A poll settled for a day after it stopped taking answers has not
happened yet, and its closing date can be five days gone while the team is still waiting
on the date they came back to the poll to find. Anchoring on the closing date alone would
delete the answer before the meeting.

**Deleting a poll deletes everything about it.** The ballots, the invitations, the
candidate days and the counter-proposals all go, by the `on delete cascade` `V1` already
declares. Afterwards a link somebody saved reads exactly as a link nobody issued: the
not-found screen, the same words either way, because `PollScreen` forwards there on a
poll it cannot read and `poll()` cannot read one that is not there. That is the whole of
what retention asked of the views — none of them changed, and `NotFoundView`'s "The link
may have expired" became true rather than aspirational.

**The names go too, but only the minted ones, and only when nothing refers to them.**
The same maximum age drops an `account` row whose address was minted for an anonymous
session (`@whichday.anonymous`) once no poll refers to it as an organizer, an invitee or a
voter. Three conditions, each earning its place:

- **Minted only.** An address a provider vouched for belongs to somebody who can come back
and be recognised. A minted one belonged to a session that no longer exists, and nothing
will ever match it again.
- **Referred to by nothing.** A name is the account table's alone (§10), so dropping a row
a live poll still mentions would render that person as their own minted address on
everybody else's screen. `PollService.addressesOnAnyPoll` is what the sweep asks, and it
asks in whole columns rather than by joining `account` — those two are deliberately not
joined anywhere.
- **Old enough.** The age is what makes it safe rather than merely tidy: a session that has
just typed a name has written its row and referred to nothing yet, so unreferenced does
not mean abandoned until no session could still be holding it.

The order in `RetentionSweep` follows from that: the polls go first, so a name whose last
mention was on a poll deleted this run is already unreferenced when the accounts are looked
at. A voter's name therefore outlives their poll by no sweeps at all, and an idle visitor's
by ninety days.

**Who may run it: nobody.** `deleteExpiredPolls` and `forgetExpiredAnonymous` take no
viewer, because there is no viewer to check — it is the one write in `PollService` that no person asked for, and the
windows are the authority instead. It is also the first thing in the application that
happens because time passed rather than because somebody looked, which is the trigger
[issue 0005](issues/0005-closing-happens-on-read.md) has been waiting for.

A fixed delay rather than a nightly cron: a cron at three in the morning is skipped
outright by a machine asleep at three, there is nothing about deleting rows that wants a
particular hour, and a fixed delay always runs shortly after start-up — which is when a
deployment that has been down for a week needs it most. The sweep is off under the `test`
profile, because one Spring context serves the whole suite and a scheduled delete would
race whatever test is running; `PollRetentionTest` calls it directly.

---

## 10. How a person is stored
Expand Down
8 changes: 8 additions & 0 deletions docs/issues/0005-closing-happens-on-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ Anything that has to happen when voting ends needs a trigger rather than a
comparison: telling the organizer the poll is theirs to settle, mailing the team the
result, or auto-locking the leading day. There is nowhere to put any of that.

## What has changed since

The scheduling itself is no longer missing. Retention added `@EnableScheduling` and a
sweep that runs on a fixed delay (`RetentionSweep`, `PollService.deleteExpiredPolls`), so
there is now a place for anything that has to happen because time passed rather than
because somebody looked. What is still missing is anything to *do* at the moment a poll
closes, which is the half below.

## What fixing it looks like

A scheduled sweep over open polls whose closing date has passed, doing whatever the
Expand Down
44 changes: 0 additions & 44 deletions docs/issues/0019-anonymous-names-accumulate-forever.md

This file was deleted.

2 changes: 2 additions & 0 deletions src/main/java/io/binarycodes/whichday/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;

import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
Expand All @@ -15,6 +16,7 @@
* works.
*/
@SpringBootApplication
@EnableScheduling
@StyleSheet(Aura.STYLESHEET)
@StyleSheet("/styles.css")
public class Application implements AppShellConfigurator {
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/io/binarycodes/whichday/base/config/Retention.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.binarycodes.whichday.base.config;

/**
* How long a poll is kept. Two windows, and a poll goes when either one has passed it.
*
* <p>{@link #afterPollEnds} is the ordinary path: a poll that is over goes a few days
* later, measured from the day it ended. {@link #maximumAge} is a ceiling measured from
* the day it was created, and it reaches every poll there is — a draft nobody sent, a
* poll still taking answers, a settled one whose day is still ahead. Nothing survives
* it, which is the point of it: the store is one file nothing else prunes ({@code
* docs/REQUIREMENTS.md} §9), so without a rule that reaches every row there are rows
* no rule reaches.
*
* <p>The ceiling therefore wins where the two disagree, and a deployment that sets
* {@code afterPollEnds} beyond it has made the longer window unreachable rather than
* made a mistake worth refusing at startup.
*
* <p>Both may be {@link RetentionWindow#NEVER} independently, which is how a deployment
* keeps everything for good.
*/
public record Retention(RetentionWindow afterPollEnds, RetentionWindow maximumAge) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.binarycodes.whichday.base.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Turns the two windows a deployment named into the bean the sweep asks.
*
* <p>Both properties are written by {@code application.properties} with a default, so
* neither is ever absent — an empty one is an operator who set the variable to nothing,
* and that is a mistake worth failing on rather than reading as "keep everything".
* {@code never} is how that is said on purpose.
*/
@Configuration(proxyBeanMethods = false)
public class RetentionConfiguration {

@Bean
Retention retention(@Value("${whichday.retention.after-poll-ends}") String afterPollEnds,
@Value("${whichday.retention.days}") String maximumAge) {
return new Retention(RetentionWindow.of("WHICHDAY_RETENTION_AFTER_POLL_ENDS", afterPollEnds),
RetentionWindow.of("WHICHDAY_RETENTION_DAYS", maximumAge));
}
}
Loading