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:
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¶
- Data-model § Person — names
notification_prefsas channel-keyed JSONB. - Data-model § Stringer — points at the per-stringer
notification_template. - v3-vision § B2 Channel exploration — the channel set's V3 evolution.
- client-identity-and-sharing — the privacy-invariant gate on which Person fields are platform-public (
notification_prefsis one of them).
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:
- 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.
- 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)¶
- Add the channel row to the canonical key-set table on this page.
- Pick a default; document who reads / who writes.
- V3 dispatcher reads the new key with the documented default.
- V3 portal exposes the new key in the UI.
- 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.