Skip to content

title: Read-time preference translation type: concept created: 2026-04-24 updated: 2026-04-24 tags: [schema-migration, read-time-translation, preferences, backward-compatibility, zero-downtime, rollback-safe, preference-system, legacy-schema, adapter-layer] sources: [2026-03-19-slack-how-slack-rebuilt-notifications] related: patterns/read-time-schema-translation, concepts/preference-schema-decoupling, concepts/backward-compatibility, concepts/coupled-vs-decoupled-database-schema-app-deploy, patterns/expand-migrate-contract, systems/slack-notifications-2-0


Read-time preference translation

Read-time preference translation is the discipline of migrating a preference schema's semantics without touching the stored bytes, by interposing an adapter layer at every read site that translates legacy preference values into new-schema values on the fly. Storage remains byte- identical to the pre-migration state; all behavioural change happens at read time.

This is a zero-database-migration alternative to the canonical expand-migrate- contract workflow for preference / user-setting schemas where:

  • Rollback safety dominates — reversing a behavioural change must be instantaneous with no data-rewrite step.
  • The number of legacy values is small and the translation rule is deterministic.
  • The new schema is additive (new columns, not replacements) so legacy values remain valid.

Slack's canonical instance

From the 2026-03-19 How Slack Rebuilt Notifications post (canonical verbatim):

"With backwards compatibility and the possibility of rollback in mind, we thought it too risky to move people from 'off' to 'mentions' at the database level. Instead, we used a read time strategy to ensure users had the same experience as before, but using the decoupled push logic. We introduced the new desktop_push_enabled pref which would be the only driver of enabling push notifications. Because this pref did not exist before, we were able to backfill all existing users based on whether they had it previously set to 'off' with no interruptions to the current experience. We then did some read time magic to make 'off' act as 'mentions' but with pushes disabled in the new world (because that is exactly how it functions today!)."

The legacy desktop: 'off' value is never rewritten. Read-time translation maps it to the new-world pair desktop: 'mentions' + desktop_push_enabled: false — preserving behavior exactly, with rollback as simple as disabling the new code path.

Contrast with expand-migrate-contract

patterns/expand-migrate-contract is a six-step discipline that ends in a contract step where legacy values are removed from storage. Read-time translation skips the contract step — the legacy values remain in storage indefinitely; the translator is permanent. This is:

  • Cheaper operationally — no backfill batch job, no write-path double-write window, no contract-step cleanup.
  • Safer for rollback — stored state never diverges from pre-migration state.
  • More expensive at read time — every read incurs a translation step (usually cheap, but non-zero).
  • A long-term schema-rot hazard — the translator becomes load-bearing infrastructure that every future change must understand.

The trade-off favours read-time translation for:

  • High-stakes user preferences where any perceived behaviour change (even a semantically-equivalent one) generates support volume.
  • Schemas with small legacy-value enumerations (Slack's was three values: everything / mentions / nothing).
  • Cases where the semantic migration is non-destructive — the new schema is a strict superset of the old, and translation is deterministic.

Preconditions

  • Deterministic translation rule: each legacy value must map to exactly one new-schema value (or tuple of values) with no user-specific context required.
  • Additive-schema-change discipline: new values live in new columns; legacy columns are left intact.
  • Every read site uses the adapter: any read path that bypasses the translator re-introduces the split-brain failure mode the migration was supposed to solve.
  • Fallback semantics are well-defined: if the translator fails or encounters an unknown value, what happens? Slack's guarantee: "push_enabled: false always means 'no push,' even during rollbacks."

Failure modes

  • Translator bypass in one code path — some legacy callsite reads the raw storage value and interprets it by legacy semantics, producing split-brain behaviour.
  • Write-time gap — if the new schema's write path doesn't properly produce legacy values in addition to new values, rollback to the legacy-only code produces empty/default preferences (the memcache incident in Slack's disclosure is a near-neighbour of this class).
  • Default-value collapse — if a cache miss or malformed field causes the system to serve a default value instead of translating the legacy value, many users can get their preferences silently reset. Slack's disclosed incident: "A malformed field once reset preferences to Mentions until we cleaned data and flushed memcache."
  • Indefinite technical-debt accrual — since there's no contract step, every future developer must learn the translation rule; every future schema change must compose with it.

Seen in

  • systems/slack-notifications-2-0 (Slack Notifications 2.0, canonical instance) — desktop: 'off' translated at read time to desktop: 'mentions' + desktop_push_enabled: false.
Last updated · 470 distilled / 1,213 read