The schema is publishable because the privacy is enforced underneath it.
From the moment a user composes a decision to the moment the verdict is in their inbox: what tables exist, what tables can read each other, what the encryption boundary looks like, where the backups go, what the retention policy is, and what a data-export looks like. The schema is publishable because the privacy is enforced underneath it, not by hiding the schema.
Eight tables, one read path.
The production schema is small. Eight tables hold everything the product needs: accounts, decisions, participants, the day-by-day votes and notes, the per-decision encryption keys, the computed verdicts, and the audit log. The table below names each, says whether it carries sealed data, and notes the role allowed to read it before verdict day.
Per-decision keys, held in Infisical.
Each decision is encrypted with its own symmetric key, generated when the decision is opened and stored in Infisical (EU region) under a namespace keyed by the decision id. Postgres holds only the Infisical reference, never the key material itself. The verdict worker fetches the key at job time, decrypts the rows it needs to compute the verdict, and discards the key from memory at the end of the job.
Keys are rotated annually on a rolling basis. The rotation re-encrypts the rows in place under a new key; the old key is retained in Infisical for 30 days against the possibility of an in-flight backup restore, then destroyed. The rotation history is auditable; the keys themselves are not exportable from Infisical except through a break-glass procedure that requires two named operators and writes a record to the audit log of a different organisation account.
Three roles, three grant sets.
The application connects to Postgres under three distinct roles. The grants are kept narrow enough that the wrong role on the wrong path returns an error before the row-level policy is consulted.
- app_vote · INSERT on
votesandvote_notes, scoped to today's date by a trigger that rejects historical rows. No SELECT on sealed columns. This is what the evening-vote endpoint uses. - app_read · SELECT on
verdictsthroughread_verdictonly, with the verdict-open guard. SELECT ondecisions,participants,accountsfor the participant's own rows. This is what the verdict-page handler uses. - app_verdict_worker · SELECT on the sealed tables, scoped at job time to a single decision id passed by the orchestrator. Runs once per decision, at verdict opening, then the connection is torn down.
The admin portal runs under a fourth role with no grants on sealed columns at all, as described in the privacy piece.
Encrypted at rest, 30 days, then gone.
The production database is backed up every six hours, with point-in-time recovery (PITR) WAL shipping in between. Backups are encrypted at rest with a separate Infisical-held key, kept on object storage in the same EU region as the live cluster, and retained for 30 days. After 30 days the backup is destroyed; the destruction is logged.
Because each decision's row-level data is encrypted with its per-decision key, a stolen backup file is not by itself a leak: an attacker would also need the corresponding Infisical keys, which are stored on a separate provider with separate access control. The two systems are designed so that compromising one does not produce a readable dataset.
Delete-on-request honoured within 24 hours, backups within 30 days.
A user can request deletion from the account page or by email to [email protected]. Deletion is honoured within 24 hours of the request. The deletion runs in two phases.
Phase one, immediate: the user's rows in accounts, participants, votes, vote_notes, verdicts, and decisions (where they are the sole participant) are removed from the live database. The corresponding Infisical key is destroyed. From this point the data is unrecoverable from the live system, even by Counsel.day staff.
Phase two, within 30 days: the rolling backup retention window expires, and the encrypted-backup copies of those rows pass out of existence as the backups age out. We do not selectively scrub backups · re-encrypting a backup would defeat the encryption-at-rest guarantee · so we wait the 30 days and let retention finish the job. The audit log retains a record that the deletion took place, but contains none of the deleted content.
If a couple decides together, the deletion is initiated by either participant. If only one participant requests deletion, their personal data is removed but the decision metadata and the other participant's rows are retained at the other participant's discretion. This is documented in the terms and the privacy page.
What the JSON looks like.
A user can request a full data export from the account page. The export is delivered as a single JSON file, signed and timestamped, containing every row scoped to that user across the schema. The shape is stable enough that we can publish a sketch of it without committing to a particular customer's contents.
{
"exported_at": "2026-05-15T19:00:00+12:00",
"account": {
"id": "acc_01H...",
"email": "[email protected]",
"display_name": "...",
"plan": "couple",
"created_at": "2026-02-10T..."
},
"decisions": [
{
"id": "dec_01H...",
"question": "Should we move to Christchurch?",
"duration_days": 30,
"mode": "couple",
"opened_at": "2026-04-12T...",
"verdict_opened_at": "2026-05-12T...",
"votes": [
{ "day": 1, "level": "Lean Yes", "note": "...", "ts": "..." },
{ "day": 2, "level": "Strong Yes", "note": "...", "ts": "..." }
],
"verdict": {
"trajectory": [ ... ],
"agreement_rate": 0.72,
"divergence_index": 0.18,
"themes": [ ... ],
"synthesis": "..."
}
}
],
"audit_events": [
{ "ts": "...", "kind": "verdict_read", "decision_id": "dec_01H..." }
]
}
The export contains only the requesting user's data. A partner's votes are never included in the requesting user's export, even after the verdict day; each participant must export their own.