Chickadee Bandit

A Secret Santa draw the organizer can't peek at

The person who runs the draw is the one person who must not see it. Where the shuffle happens, and the feature we built and then deleted.

Engineering › Secret Santa without a peek

Secret Santa has a property most software features do not: there is one person who obviously has to be trusted, and that person is precisely the one who must not be able to cheat. Somebody presses the button that runs the draw. If pressing the button shows them who got whom, the game is over and nobody will tell them so.

It is a small feature. It is also the clearest demonstration we have of the thing the whole product rests on, which is why we keep pointing at it.

The version that leaks, and why it is the default

The obvious implementation runs the shuffle in the browser. It is one function, it needs no server work, and it is what almost every tutorial does.

It also hands the complete giver-to-receiver map to whoever clicked the button. Not through a bug — through the network tab. The shuffle ran on their machine, so the answer existed on their machine, and no amount of masking the table afterwards can retract it. You cannot fix this with permissions on the results, because the leak happened before the results were stored.

So the draw runs on the hub. The server reads the participant list, computes the pairing, writes the rows itself, and the response carries no pairing data at all. The mapping never exists in any browser, the organizer's included.

One cycle, so nobody draws themselves

The shuffle is Sattolo's algorithm, which is the ordinary Fisher-Yates shuffle with the range shortened by one:

const cycle = [...ids];
for (let i = cycle.length - 1; i > 0; i--) {
  const j = randomInt(i);        // note: i, not i + 1
  [cycle[i], cycle[j]] = [cycle[j], cycle[i]];
}

That single character is the whole trick. Fisher-Yates produces any permutation, including ones where somebody is left holding their own name. Sattolo produces exactly the permutations that form one single cycle, so following giver to receiver visits every participant once and returns to the start — nobody draws themselves, and the family is one chain rather than two couples quietly swapping with each other.

`randomInt` rejection-samples the system random source rather than taking a modulo, because a biased shuffle is the kind of bug that never shows up in testing and shows up in year three when somebody notices a pattern.

The rows exist, and the table refuses to serve them

The pairings go into an ordinary table in the household's database. What makes them secret is the access rule declared on that table:

{
  "assignments": {
    "kind": "sealed_until",
    "fk_column": "draw_id",
    "parent_table": "draws",
    "writer_column": "member_id",
    "parent_status_column": "status",
    "visible_parent_status_values": ["revealed"],
    "endpoint_writes_only": true
  }
}

Read that as: a member sees their own row, and sees everybody else's only once the parent draw has been flipped to `revealed`. The hub appends that condition to every query the app runs against the table — the app cannot opt out, because the rewriting happens below it.

`endpoint_writes_only` is the other half. It blocks the app from writing this table through the ordinary data path, so the only thing that can create an assignment is the draw endpoint itself. Without it, an app could simply insert its own pairings, and every guarantee above would be decoration.

The feature we built, reviewed three times, and deleted

The most requested addition was "don't give me the same person I had last year." We built it. It went through three security review rounds. Then we removed it entirely, and we would rather explain that than quietly ship it.

The problem is not a bug, it is the shape of the thing. Feeding last year's sealed pairings into this year's draw makes last year's secrets an input to a computation whose output people can see. The output that matters is not the response body — it is the recipient each participant reads on their own screen. Give someone a re-draw button and they can sample that distribution: the names that never come up are the ones the constraint is quietly excluding, and those are somebody else's sealed pairings from last year.

Making the HTTP responses identical does not close it, because the response was never the channel. Of twelve findings across those three review rounds, roughly eleven came from that one option. The other feature in the same batch — "these two people should never be paired," which takes its rules from a table the members can already see — produced one.

The line we ended up with: a secret can be an input to a computation only when the computation's output is already at least as secret as the input. Last year's draw and this year's draw are not, so they do not get to talk.

Two rules and four people is a solved puzzle

The exclusion rules that did ship have a failure mode of their own, and it is a nice one.

Exclusion rules are visible to members, by design — you should be able to see that your household has agreed the two of you do not draw each other. But rules plus arithmetic can narrow the space to a single answer. Two symmetric rules over four participants was enough: we ran the draw two hundred times and got one distinct mapping, every time. Anyone who can read the rules can run the solver themselves and know the entire result before the reveal.

So the draw computes, before it commits anything, how many possible givers and receivers each participant actually has, and refuses outright when anyone's is down to one:

409 constraints_too_revealing

Two-person exchanges are exempt, because they are pinned by arithmetic rather than by anybody's rule, and telling two people that their gift exchange is too revealing would be silly.

This only bounds the sharpest case. Rules can still narrow the space without pinning it completely, and counting the exact number of valid draws is far too expensive at our participant limit. We would rather refuse the provably-broken case and say plainly that we do not refuse every merely-narrow one.

Why a party game is the honest demo

None of the above is about Secret Santa. The draw runs on the server because that is where the answer can exist without a person holding it. The table refuses to serve rows the reader is not entitled to, because that rule is enforced beneath the app rather than by it. A feature got deleted because information flowed the wrong way, and no amount of care at the endpoint was going to fix a design that leaked by construction.

That is the same argument as the one about third-party apps and family data, with the stakes turned down to a gift exchange — which is exactly why it is the one worth reading. You can check this one yourself, in December, with your relatives.

Read next

Written at Chickadee Bandit, a private hub for running a household — the thing all of this is load-bearing for. More engineering posts.