Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 We Mask Country Pairs Before Storing Live Events

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Cloudflare Workers

We Mask Country Pairs Before Storing Live Events

We omit names, coarsen times and mask country pairs before storage, then derive every public view from that record and remove its id at runtime.

On this page
  1. Why a country can be an identity
  2. What the sender leaves out
  3. Why we discard unknown keys
  4. Masking before storage
  5. Where the counts come from
  6. One object, one projection
  7. On the page
  8. What guards it
  9. When this is the wrong pattern
  10. Verify this
  11. Where our evidence stops
  12. Frequently asked questions

For TEEI’s public feed of completed conversations, we omit names and coarsen times and durations. Someone who knows a participant’s circumstances may still recognise them from a country pair, a settlement time and a duration when few people take part. We therefore decide what geography to keep before we store an event in a Durable Object. Since 14 October 2026, we keep both countries only when the latest counts we hold put each at 20 or more active participants over the last 90 days; otherwise, we replace both with UN sub-regions. We derive every public view from that stored event. We have not measured whether the resulting feed prevents recognition.

On our home page, as built on 14 and 15 October 2026, one such event becomes a line like “Verified 4 min ago · TEEI Language · Norway ↔ Ukraine · 30 min on video” (the countries and figures here are an example, not a session). When we checked production on 15 October 2026, the line did not yet appear there. Below we follow one event from a session settled in TEEI Language or TEEI Mentorship, through the website’s Worker, to the browser. The platforms behind it are described in How TEEI Runs Its Platforms on Cloudflare Workers, the lead of this reading path.

Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.

Why a country can be an identity

If only two volunteers in a country take part, a line that names that country, the minute a session was settled and its length may help someone who knows their circumstances infer which of the two took part. Over a week, those times could also suggest that participant’s routine.

We have changed our mind on this once. On 22 July 2026 we took country pairs out of TEEI’s public statistics altogether and published aggregates only. On 14 October 2026 we changed the design to bring country pairs back under the threshold, and decided to apply it in the Durable Object that stores the feed rather than in the page that shows it.

What the sender leaves out

We create the event in sender code that the Language and Mentorship Workers share. A session counts as completed only when the platform holds verified proof of attendance. We write the settlement and queue its outbox effect in the same database batch, and we perform the website post later from the dispatcher, so a failed post cannot undo that batch.

The body contains an opaque event id, the programme, the country on each person’s profile as an ISO 3166-1 alpha-2 code, the minutes and the minute the session was settled. It contains no name, no email, no city, no account id and no booking id. The volunteer is from and the learner is to, and we leave out a profile country we cannot map to a code. We skip sessions where either side is one of our synthetic test people before anything is posted.

As of 15 October 2026, we take the minutes from the time both people were connected to the video, not from the booked length, round them to the nearest five with a minimum of five, and cut the settlement time to the minute. This is coarsening, not anonymisation.

We leave out how the request is authenticated. What matters for the design is delivery: as of 15 October 2026, we make at most five attempts from the outbox, so delivery is bounded and not guaranteed, and the same event can arrive more than once. We suppress repeats at the receiver; we do not recover an event that never arrives.

Why we discard unknown keys

On the website’s Worker, we validate each event with a zod object schema before it reaches the Durable Object. The detail worth copying is what the schema lacks: .strict(). We discard undeclared keys instead of rejecting an otherwise valid event, and we pin that behaviour in a test. If a sender change one day adds a name or a city to the body by mistake, we keep the known fields and drop the accidental addition; with a strict schema, we would reject the whole event.

We run the same parser before forwarding the event and again inside the object, and we forward the parsed event rather than the original request body, so we do not carry discarded keys across that boundary.

Masking before storage

On the website, each event passes three steps: we validate it, build a masked record and remove the record’s id before publication. We build the masked record in a pure function, which we test as ordinary TypeScript with a local region table and no Durable Object API:

Verbatim excerpt.

ts
/** True when both codes are known and both meet the threshold. */
export function pairMeetsThreshold(
  from: string | undefined,
  to: string | undefined,
  active: ActiveCountries,
  threshold: number
): boolean {
  if (!from || !to) return false;
  if (!subregionOf(from) || !subregionOf(to)) return false;
  return (active[from] ?? 0) >= threshold && (active[to] ?? 0) >= threshold;
}

function place(code: string | undefined, showCountry: boolean): LivePlace | undefined {
  const region = subregionOf(code);
  if (!region) return undefined;
  return { country: showCountry ? (code as string) : null, region };
}

/** The event as the Durable Object stores and broadcasts it: masked before anything else. */
export function maskLiveEvent(event: LiveEvent, active: ActiveCountries, threshold: number): StoredLiveEvent {
  const showPair = pairMeetsThreshold(event.from, event.to, active, threshold);
  const stored: StoredLiveEvent = {
    id: event.id,
    type: event.type,
    programme: event.programme,
    minutes: event.minutes,
    outcome: event.outcome,
    settledAt: event.settledAt,
  };
  const from = place(event.from, showPair);
  const to = place(event.to, showPair);
  if (from) stored.from = from;
  if (to) stored.to = to;
  return stored;
}

We calculate showPair once and pass it to both place calls, so one country below the threshold masks both sides. A country missing from the counts counts as zero, so when the count map is empty, we mask both countries. An event with only one known side shows that side’s region only, because a pair needs two. We build the output field by field and never copy the raw from and to strings, so a stored place is { country, region }, { country: null, region } or absent.

We map codes to UN M49 sub-regions such as Northern Europe and Eastern Europe. For an unknown code we return no region, and the event then carries no place for that side. As of 15 October 2026, the threshold is a constant of 20 in the Durable Object, which we pass into the function; we neither expose it to the page nor allow the page to override it.

That is the point of the design: a country removed before storage cannot reappear in any view of this stored feed.

The same sample event in three states. As sent, it carries an opaque id, the programme, two country codes, 30 minutes and the settlement minute, and no person. As stored, the countries become objects with a region; with 20 or more active participants in both countries the country codes stay, with 19 in one of them both become null. As sent to browsers, the event is the stored one without its id.
Figure 1. One sample event, sent, stored and published. The countries and figures are a test fixture, not a session. Source: TEEI, the live feed’s code as of 15 October 2026.

Where the counts come from

As of 15 October 2026, we count active volunteer records and learner accounts per profile country with COUNT(DISTINCT ...), so repeated sessions do not raise a country’s count. Each needs a verified completed one-to-one session whose booked end falls within the last 90 days, and we exclude sessions with synthetic test people. We map country names to ISO codes, adding counts that resolve to the same code, and fetch the result over HTTP from the platform’s public statistics, linked in the lead note, like any other client.

As of 15 October 2026, we cache the counts in memory and in the object’s storage. In the constructor, we restore the stored copy inside blockConcurrencyWhile, so no event is handled before the cache is back. We reuse the counts for one hour after a successful read and five minutes after a failed one, both measured from the start of the attempt; after that, the next incoming event can trigger a fresh read. When we have no country counts, we mask every pair.

One object, one projection

The receiver is a single Durable Object for the whole site that stores the masked events and supplies every public view, and we keep the events in its SQLite storage. In its ingest method, we parse and mask each incoming event, then write it to SQL and broadcast its public form. As of 15 October 2026, we keep the newest 200 events. We suppress repeats against retained records; bounded history and concurrent arrivals do not establish exactly-once publication or display.

The id is the only stored field that does not leave the object, and we send every outgoing copy through one function:

Verbatim excerpt.

ts
export function toPublicView(event: StoredLiveEvent): PublicLiveEvent {
  const { id: _id, ...publicEvent } = event;
  return publicEvent;
}

We cannot rely on the type alone to exclude the id. PublicLiveEvent is Omit<StoredLiveEvent, 'id'>, but Omit deletes nothing at runtime, and a StoredLiveEvent stays assignable to PublicLiveEvent with its id intact, so we remove the id at runtime. We apply that projection to the HTTP snapshot, the socket’s opening hello and every broadcast.

The HTTP snapshot and the socket’s opening hello share a bounded public history of recent events, and the socket then receives each new event. We accept sockets through the WebSocket Hibernation API with a live tag and ask the runtime for the tagged sockets on each broadcast, so the loop does not depend on an in-memory connection array. We answer the upgrade in the Worker entry and return the object’s response directly, so the 101 response never passes through Astro.

We let the runtime answer a browser’s ping with pong, which the State API documentation describes as happening without waking a hibernating object. On 15 October 2026 at 10:47 UTC, we sent ping on the production socket from our test client and received pong. We never send pong from our handler, so we received the runtime’s auto-response. We did not establish whether the object was hibernating, and we have not measured what hibernation saves.

On the page

As of 15 October 2026, we defer the line on the home page with server:defer as an Astro server island (see static pages, live data). In the island, we read the newest public event through the Durable Object binding, falling back to the HTTP snapshot. We show a masked side as its region name and a kept country in the reader’s language with Intl.DisplayNames. When there is no event, we render a fixed fallback sentence, never an invented one. Where that sentence comes from changed on 15 October 2026. In the version we examined for this note, the page also prerendered it in the island’s fallback slot; a later revision that day removed the slot and moved the flag check into the island, which renders the sentence only when the line is switched on and has no event.

What guards it

We guard the storage shape and the public shape separately. In the storage test, we assert the exact key set and require from and to to be objects. In the projection test, we assert that id is absent while from and minutes remain. On the sending side, we check the rounding, the minute cut, the opaque id, the unplaceable country and the skipped test people. We also test selection of the newest public event, supported response shapes and handling of malformed rows. Our record of a run on 15 October 2026 shows 56 passing tests in four website test files; the selection tests are not counted in that run.

When this is the wrong pattern

One object for everything. Cloudflare’s guidance is to model Durable Objects around the “atom” of coordination, and its December 2025 changelog warns against “a global singleton that becomes a bottleneck”. Our atom is the whole home page, with one event per completed session: the public statistics snapshot timestamped 11:14 UTC on 15 October 2026 showed 3 verified completed sessions whose booked end fell on that UTC date. Our judgement, not a measurement, is that one object fits this volume. We broadcast each event to the connected clients, so a larger deployment should load-test that fan-out before relying on one object.

The threshold is per country. We mask countries with few active participants to limit geographical detail; assess the whole feed against your own population’s disclosure model.

Coarse is not anonymous. A minute and a five-minute duration are still facts about a meeting. If you cannot defend publishing the time of day at all, publish hourly counts instead of events. For a new population, choose the geographical granularity and the suppression rule together, then test the result against your own disclosure model.

Delivery is bounded and may repeat. Design and test how long you remember ids separately from how long you keep events, and do not promise exactly-once display.

Counts from elsewhere are the latest you hold. We decide on cached counts, not on the current situation, and fetching them puts a dependency in the path that stores events; we accept that so we can define activity where we hold the verified session records. Specify and test a refresh and failure policy before you rely on a cache.

Verify this

Copy pairMeetsThreshold, place and maskLiveEvent into your own TypeScript project, with minimal types, a subregionOf backed by { NO: 'Northern Europe', UA: 'Eastern Europe' } and a sample event from NO to UA. Mask it with a threshold of 20 and the counts { NO: 20, UA: 300 }, then { NO: 19, UA: 300 }, then {}, and once more with from: 'ZZ'. You should get both countries, then both regions only, then both regions only, then no place for the unknown side. Figure 1 draws the first two results.

Where our evidence stops

The code in this note is as it stood on 15 October 2026. On that day we checked the socket and the public read separately; we have not measured a real session travelling from settlement to the browser.

Frequently asked questions

How do you show live activity without publishing who took part?

With two layers. As of 15 October 2026, at the sender we never include a name, email, city, account or booking id, and we round the time to the minute and the duration to the nearest five minutes. As of 15 October 2026, in the Durable Object that receives the event we replace both countries with their UN sub-region unless the latest counts we hold put each country at 20 or more active participants over the last 90 days, and we do that before anything is stored or broadcast.

Why mask in the Durable Object and not in the UI?

Because a country removed before storage cannot reappear in any view of this stored feed. We keep the threshold next to the storage write, so no page that reads the feed can show more than the object kept.

Is removing the name field enough to make an event anonymous?

No. A country pair, the minute a session was settled and its length can together help someone pick out one person when few people in that country take part. We do not call the feed anonymous. We call it a feed without identities, and we use the masking rule to mask countries with few active participants.

More in Research & Engineering