← Engineering · The data architecture
DATA, END TO END

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.

Reading time14 minutes
DatabasePostgreSQL 16, single-tenant cluster
SecretsInfisical (EU region)
THE SCHEMA OVERVIEW

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.

Table
Holds
Notes
Sealed
accounts
User accounts
Email, display name, plan tier
No password column · sign-in is magic-link only, via Brevo.
No
decisions
Decisions
Question, duration, mode, status
Question text is encrypted with the per-decision key; metadata (duration, mode) is plaintext.
Partly
participants
Decision membership
Account id, decision id, role
Roles: opener, partner, family-member. No sealed payload.
No
votes
Daily votes
Lean / Yes / No level, day index
Level encrypted with the decision key. Day index plaintext, used for the partner indicator.
Yes
vote_notes
Why the vote was that level
Free-text note per vote
Encrypted with the decision key. Maximum 1,200 characters per note.
Yes
verdicts
Computed verdicts
Trajectory, themes, synthesis
Written by the verdict worker after the verdict day; readable to participants only.
Yes, until verdict day
decision_keys
Pointer to Infisical key id
Per-decision key reference
No key material in Postgres; only the Infisical key id and rotation history.
No
audit_log
Operator and read events
Append-only, separate cluster
Two-year retention. Surfaced to users for their own decisions on data-export request.
No
THE ENCRYPTION BOUNDARY

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.

WHAT READS WHAT

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 votes and vote_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 verdicts through read_verdict only, with the verdict-open guard. SELECT on decisions, participants, accounts for 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.

BACKUPS

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.

RETENTION AND DELETION

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.

DATA EXPORT

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.

Read next · The math behind the verdict →

Top