Skip to content

Notification preferences — JSONB key set (single source of truth)

Status: locked 2026-05-09 (Stefan); landed in V2 schema-hooks bundle (racket-book#41 / B8). Owner: Theo (SA). V2 reads: email only. V3 reads: the full key set; this page is the contract V3 is built against.

This page is the canonical key-set for the persons.notification_prefs JSONB column (and the per-stringer override on client_profiles.notification_prefs if/when that column lands per client-management-v2 § Hook 2). The point is to fix the V3 contract at V2 schema time so V3 can light up new channels by writing into the same object — no parallel column, no schema migration.

The JSONB column itself ships in alembic/versions/0001_baseline_identity_and_sharing.py (persons.notification_prefs, NOT NULL DEFAULT '{}'::jsonb); this page is the documentation-only half of the V2 schema-hooks bundle (v3-vision § V2 hooks to consider landing now).

Cross-references

Canonical key set

The blob is a flat object — no nesting beyond what's documented below. Unknown keys are tolerated (forward-compatible: V2 writers do not strip keys V3 added) but never inspected by V2. Type-mismatched values fall through to the documented default at read time.

Key Type Default V2 reader V3 reader Notes
email boolean true receipt-email path (M14) dispatcher Master opt-out for the email channel. Locked default true so V2 receipts always email unless the human opts out.
sms boolean false (none) V3 dispatcher Reserved. V2 never sends SMS.
whatsapp boolean false (none) V3 dispatcher Reserved. V2 never sends WhatsApp.
telegram boolean false (none) V3 dispatcher Reserved. V2 never sends Telegram.
signoff_opt_out boolean false (none) V3 sign-off workflow (B1) Reserved for V3 client portal. NOT the same as ClientProfile.signoff_pref — that is the per-stringer structural preference; this key is a channel-style flag in case the V3 dispatcher chooses to express it here too. (Locked decision: source-of-truth is the column; this key is documentation-only and may stay unused.)

Default value

V2 writers seed the blob as {} (the column default). V2 readers treat missing keys as the per-key default in the table above — concretely, email defaults to true even when the row stores {}, so a fresh Person whose blob is empty still receives receipt emails.

The V2 settings UI (M16, settings-v2.md) does not surface the blob; the blob lives on Person (platform-level) and the V2 settings page is per-stringer. The V3 client portal is where the human edits their own blob.

Who reads / who writes

Layer Reads Writes
V2 receipt-email path (M14) email (none)
V2 admin / DSAR cascade (R-FADP-2) (none) scrubs the whole blob to {} on hard-erase per the FADP cascade
V3 dispatcher every channel key (none — the dispatcher reads, the portal writes)
V3 client portal (C1) every key + signoff_opt_out every key + signoff_opt_out
V3 stringer-side per-client overlay (TBD per client-management-v2 § Hook 2) (TBD; same Hook-2 row)

Why no schema enforcement

The contract is documentation-only. The blob is JSONB so V3 can add a new channel — say, signal: false — without touching the schema, and V2 readers ignore the unknown key. Schema-level enforcement (e.g. a JSON-schema CHECK constraint) was considered and rejected for two reasons:

  1. V3 evolves the key set. A schema CHECK would force a migration on every new channel — exactly the cost the JSONB shape is designed to avoid.
  2. The contract is what readers expect, not what writers ship. V2 readers default missing keys per the table above; a typo'd key doesn't break the read path, it just doesn't change the per-channel default. The runtime cost of a typo is bounded; a CHECK constraint would convert a typo into a hard insert failure with no upside for V2.

The single source of truth is therefore this page. V2 + V3 readers both spell their key constants the same way ("email" etc.) and this page is what they consult.

Adding a new channel (V3 procedure)

  1. Add the channel row to the canonical key-set table on this page.
  2. Pick a default; document who reads / who writes.
  3. V3 dispatcher reads the new key with the documented default.
  4. V3 portal exposes the new key in the UI.
  5. No schema migration required. Existing rows whose blob lacks the new key fall through to the default at read time.

Cross-product with the per-stringer overlay (Hook 2, future V3)

client-management-v2.md § Hook 2 reserves a per-stringer-overlay column on ClientProfile (column name TBD) for the "this client prefers SMS for THIS stringer specifically" case. The overlay's value-set is the same as Person's blob — same keys, same types, same defaults. Effective resolution at V3 dispatch time:

effective_channel_pref = client_profile_overlay.get(channel) ?? person.notification_prefs.get(channel) ?? channel_default

The overlay column does NOT ship in V2 (issue tagged for V3); Person.notification_prefs is the only reader/writer surface today. This page documents the resolution rule so V3 doesn't reinvent it.