How the seal is enforced, layer by layer.
The promise that neither partner sees the other's votes until verdict day, and that no Counsel.day operator reads them through the application or the service-role key at any point, has to be enforced somewhere that does not depend on trust. Trust is fragile; database structure is not. This piece walks each enforcement layer from the threat model down to the workflows we deliberately refused to build · including the one exception where, on paid tiers, an anonymised copy of the notes is sent to our AI vendor (zero-retention API) on verdict day to write the synthesis paragraph.
What could go wrong if the seal failed.
The threat model is small and specific. A user of Counsel.day is trusting us not to leak their daily votes or the notes attached to them, in three distinct directions. To their partner, before the verdict day. To Counsel.day staff, ever. To anyone else · a subpoena, a database breach, a hostile insider, a third-party analytics vendor accidentally hoovering form values.
The first of these three is the load-bearing one for the product. If either partner can read the other's vote mid-period · even indirectly, through a count, a rounded average, an indicator of conviction strength · the anchoring problem the product exists to solve has reappeared inside the product itself. The other two are the load-bearing ones for the business. If staff can see votes, the product has become a confessional kept by strangers; if an attacker can see votes, every user has to assume an audience.
Each subsequent section addresses one direction of leakage. The threat model is the test: a change to the codebase that loosens any of these directions, even on a code path that "would never be called in production," is a regression.
Row-level security in Postgres.
The votes table holds one row per (decision, participant, day). Row-level security is enabled on it at the table level, and the policy is the smallest, strictest thing we could write: a participant may select rows belonging to their own user id, and only when the decision is past its verdict day. Before that day the only path that reads votes is the verdict-computation worker, which runs under a service role with a narrow grant scoped to a single decision id passed in at job time.
The policy is shaped so the partner case is impossible to express. There is no condition like "participant is in this decision" · only "participant is this user, and the verdict is open." A bug in application code that forgets to scope a query, or a future engineer who tries to add a "preview" feature, will get an empty result set rather than a leak.
-- Enabled on votes, vote_notes, conviction_smoothed
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
ALTER TABLE votes FORCE ROW LEVEL SECURITY;
CREATE POLICY votes_owner_after_verdict
ON votes
FOR SELECT
TO app_user
USING (
participant_id = current_setting('app.user_id')::uuid
AND EXISTS (
SELECT 1 FROM decisions d
WHERE d.id = votes.decision_id
AND d.verdict_opened_at IS NOT NULL
AND d.verdict_opened_at <= now()
)
);
-- No UPDATE or DELETE policy is granted to app_user on votes.
-- The only mutator is the evening-vote endpoint, which runs as
-- a separate role with INSERT only, scoped to today's date.
The same shape repeats on vote_notes (the free-text Why a vote was cast that way) and on the daily conviction_smoothed view that the verdict worker consumes. Three tables, three policies, all expressed in the same primitive: own id, after verdict.
No admin override; the sealed path is the only path.
The application has no "view as user" mode for sealed data. The admin portal can see, for any decision, that votes were cast on certain dates · the timestamps are not sealed, because the product needs to display the "your partner has voted today" indicator · but the verdict value, the vote level, and the note text are unreadable to admin code paths before the verdict opens.
This is enforced two ways. First, the admin service runs under a role whose grants exclude the sealed columns entirely; a stray SELECT * from an admin handler returns columns minus the sealed ones, by table grant. Second, the read functions for sealed data accept an explicit "verdict-open guard" parameter; if the guard is false the function returns null, regardless of caller. We test this with a static analyser in CI that flags any admin-namespace function that touches a sealed column.
The application layer also enforces the invariant that there is one and only one read path for sealed data: read_verdict(decision_id, user_id). Every UI surface that displays a verdict goes through this function. There is no second, "convenient" path used by, for example, the email-render service. The email-render service is a client of read_verdict, like everything else.
An admin who wanted to see a user's votes mid-period would have to write code that the static analyser would block before the pull request was reviewable. That is the threshold we hold ourselves to: not "we wouldn't do that" but "we couldn't do that without writing a commit a reviewer would notice."
Which endpoints can ever return sealed data.
The API surface is split into three sub-routers, each on a different mount point, each with a different authentication policy and a different list of grants on the database role it uses.
- The vote router at
/api/voteaccepts INSERTs into the day's row only. Its database role has no SELECT on sealed columns. It cannot, by capability, ever return a sealed value. - The verdict router at
/api/verdictserves the open verdict, after the verdict-opened guard has fired. Its role can SELECT sealed columns throughread_verdictonly. - The admin router at
/admin/apiserves operational metadata: decision counts, timestamps, billing, support tickets. Its role has no grant on sealed columns at all.
A request that reaches the wrong router by misrouting returns a 404 from the application before the database is touched, because the handler does not exist on that mount point. A request that reaches the right router with the wrong scope returns a 403 from the verdict-open guard. A request that reaches the right router with the right scope returns the verdict.
Who could observe what, and what we log to detect a leak.
Every call to read_verdict is logged with caller id, decision id, the verdict-opened-at timestamp the call saw, and the request id of the HTTP request that triggered it. Every change to a row-level security policy is logged at the database level and shipped to the audit store. Every grant change on every role is logged. Every admin login, every support-tool action, every export.
The audit trail is append-only and lives in a separate Postgres cluster under a separate role; the production database cannot delete from it, and the admin portal has no UI for editing audit rows. We run a nightly anomaly job: a verdict read that happened more than once for the same user-decision pair in a short window, a verdict read whose timestamp falls before the verdict-opened-at on the corresponding decision, a grant change outside business hours. Each anomaly raises an incident.
Audit logs are retained for two years and surfaced to the user on request, scoped to their own decisions, as part of the GDPR data-export workflow described in the data-architecture piece.
The absences are the architecture.
Several features that a different team would have built are missing from Counsel.day on purpose. Their absence is more important than any policy or grant, because anything we did not build cannot leak.
- No encryption-key escrow. Per-decision keys are held in Infisical and never written to a recovery vault. If a user loses access to their account, we cannot recover their sealed data; we can only delete the decision on request.
- No operator decrypt path. There is no internal tool, no support runbook, no "break-glass" SQL function that decrypts a sealed value. Support cannot read a user's votes for any reason. If a user opens a support ticket about a specific verdict, they include the relevant detail themselves.
- No "preview the verdict" feature. The verdict cannot be unsealed early. We have been asked. The answer is no, because the moment a path exists to unseal early, the path exists to unseal early for the partner.
- No third-party analytics inside the app. The marketing surfaces carry GA4 (loaded via
ga4.js, declared in the cookies and sub-processor pages). The in-app surfaces · vote, verdict, account · carry no third-party scripts at all, so no analytics provider can ever observe sealed form values.
The privacy mechanism is the sum of these layers, plus these absences. We can describe it in public because it is publishable; the schema is enforced, not hidden. The companion piece on the data architecture walks the same system from the other direction: what tables exist, what is encrypted, what is exported, what is deleted, and on what schedule.