Skip to content

Commit 3171c14

Browse files
committed
fix: remove raw SQL from notifications skill, use high-level guidance
constructive-skills should contain SDK-level guidance, not raw SQL. Rewrites deliverability.md and tables.md to be high-level advice: - Delivery status lifecycle as a table, not code - Webhook ingestion as numbered steps, not db.query() calls - Provider notes (SES/SendGrid/Twilio) without SQL examples - Suppression lifecycle as prose, not INSERT/SELECT statements - Monitoring as metric descriptions, not query blocks - Tables as narrative descriptions, not column-by-column listings Raw SQL examples remain in constructive-db-notifications skill in constructive-db where they belong.
1 parent eb0c2c9 commit 3171c14

3 files changed

Lines changed: 74 additions & 248 deletions

File tree

-303 Bytes
Binary file not shown.

.agents/skills/constructive-notifications/references/deliverability.md

Lines changed: 57 additions & 213 deletions
Original file line numberDiff line numberDiff line change
@@ -2,242 +2,86 @@
22

33
How to handle bounces, complaints, and address suppression in the Constructive notification system.
44

5-
For SQL-level internals (generator source, index details), see the `constructive-db-notifications` skill in constructive-db.
6-
7-
## Setting Up Provider Webhooks
8-
9-
### Amazon SES
10-
11-
1. **Create an SNS topic** for bounce/complaint notifications:
12-
```bash
13-
aws sns create-topic --name ses-notifications
14-
```
15-
16-
2. **Subscribe your webhook endpoint** to the topic:
17-
```bash
18-
aws sns subscribe \
19-
--topic-arn arn:aws:sns:us-east-1:123456789:ses-notifications \
20-
--protocol https \
21-
--notification-endpoint https://api.yourapp.com/webhooks/ses
22-
```
23-
24-
3. **Configure SES to publish to the topic:**
25-
```bash
26-
aws ses set-identity-notification-topic \
27-
--identity your-domain.com \
28-
--notification-type Bounce \
29-
--sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications
30-
31-
aws ses set-identity-notification-topic \
32-
--identity your-domain.com \
33-
--notification-type Complaint \
34-
--sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications
35-
```
36-
37-
4. **Webhook handler pseudocode:**
38-
```ts
39-
async function handleSESWebhook(payload) {
40-
const message = JSON.parse(payload.Message);
41-
const messageId = message.mail.messageId;
42-
43-
// 1. Correlate via provider_message_id
44-
const logRow = await db.query(
45-
`SELECT * FROM notification_delivery_log WHERE provider_message_id = $1`,
46-
[messageId]
47-
);
48-
49-
// 2. Update delivery log status
50-
if (message.notificationType === 'Bounce') {
51-
await db.query(
52-
`UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`,
53-
[message, messageId]
54-
);
55-
56-
// 3. Suppress hard bounces
57-
if (message.bounce.bounceType === 'Permanent') {
58-
for (const recipient of message.bounce.bouncedRecipients) {
59-
await db.query(
60-
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
61-
VALUES ($1, 'email', 'hard_bounce', 'ses_webhook', 'ses', $2)
62-
ON CONFLICT (address, channel_type) DO NOTHING`,
63-
[recipient.emailAddress, message]
64-
);
65-
}
66-
}
67-
}
68-
69-
if (message.notificationType === 'Complaint') {
70-
await db.query(
71-
`UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`,
72-
[message, messageId]
73-
);
74-
75-
for (const recipient of message.complaint.complainedRecipients) {
76-
await db.query(
77-
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
78-
VALUES ($1, 'email', 'complained', 'ses_webhook', 'ses', $2)
79-
ON CONFLICT (address, channel_type) DO NOTHING`,
80-
[recipient.emailAddress, message]
81-
);
82-
}
83-
}
84-
}
85-
```
86-
87-
### SendGrid
88-
89-
1. **Configure Event Webhook** in SendGrid dashboard → Settings → Mail Settings → Event Webhook.
90-
2. Set the POST URL to `https://api.yourapp.com/webhooks/sendgrid`.
91-
3. Enable: `bounce`, `dropped`, `spamreport`, `delivered`.
92-
93-
4. **Webhook handler pseudocode:**
94-
```ts
95-
async function handleSendGridWebhook(events) {
96-
for (const event of events) {
97-
const messageId = event.sg_message_id?.split('.')[0]; // strip filter suffix
98-
99-
if (event.event === 'bounce') {
100-
await db.query(
101-
`UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`,
102-
[event, messageId]
103-
);
104-
await db.query(
105-
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
106-
VALUES ($1, 'email', 'hard_bounce', 'sendgrid_webhook', 'sendgrid', $2)
107-
ON CONFLICT (address, channel_type) DO NOTHING`,
108-
[event.email, event]
109-
);
110-
}
111-
112-
if (event.event === 'spamreport') {
113-
await db.query(
114-
`UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`,
115-
[event, messageId]
116-
);
117-
await db.query(
118-
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
119-
VALUES ($1, 'email', 'complained', 'sendgrid_webhook', 'sendgrid', $2)
120-
ON CONFLICT (address, channel_type) DO NOTHING`,
121-
[event.email, event]
122-
);
123-
}
124-
}
125-
}
126-
```
127-
128-
### Twilio (SMS)
129-
130-
Configure `StatusCallback` URL when sending:
131-
132-
```ts
133-
const message = await client.messages.create({
134-
to: '+15551234567',
135-
from: '+15559876543',
136-
body: 'Your verification code is 123456',
137-
statusCallback: 'https://api.yourapp.com/webhooks/twilio'
138-
});
139-
140-
// Store message.sid as provider_message_id in delivery_log
141-
```
5+
For SQL-level internals (generator source, table DDL, indexes, monitoring queries), see the `constructive-db-notifications` skill in constructive-db.
1426

143-
Handle `undelivered` and `failed` statuses to suppress undeliverable phone numbers.
7+
## Delivery Status Lifecycle
1448

145-
## Suppression List Management
9+
Notifications move through these statuses as they're processed by the delivery worker:
14610

147-
### Checking before send
11+
| Status | Meaning | Set by |
12+
|--------|---------|--------|
13+
| `pending` | Created, not yet picked up | Default on insert |
14+
| `sent` | Provider accepted the message | Delivery worker |
15+
| `delivered` | Provider confirmed delivery | Webhook handler |
16+
| `failed` | Delivery attempt failed (bad endpoint, auth error) | Delivery worker |
17+
| `throttled` | Provider rate-limited the request (retry later) | Delivery worker |
18+
| `grouped` | Collapsed into a digest batch, no individual send | Digest worker |
19+
| `bounced` | Provider reported a hard or soft bounce | Webhook handler |
20+
| `complained` | Recipient marked the message as spam | Webhook handler |
14821

149-
The delivery worker runs this before every send:
22+
### Bounced vs Complained
15023

151-
```sql
152-
SELECT EXISTS (
153-
SELECT 1 FROM notification_suppressions
154-
WHERE address = $1 AND channel_type = $2
155-
) AS is_suppressed;
156-
```
24+
These are different signals requiring different responses:
15725

158-
### Viewing recent suppressions
26+
- **Bounced** — the mailbox doesn't exist, is full, or the domain is unreachable. Hard bounces mean "never send to this address again."
27+
- **Complained** — the recipient actively clicked "Report Spam." This is a CAN-SPAM/GDPR signal. ISPs penalize complaint rates above 0.1%, so this is more damaging to sender reputation than bounces.
15928

160-
```sql
161-
SELECT address, channel_type, reason, source, provider, created_at
162-
FROM notification_suppressions
163-
ORDER BY created_at DESC
164-
LIMIT 50;
165-
```
29+
Both should result in suppression entries to prevent future sends.
16630

167-
### Removing a suppression
31+
## Webhook Ingestion
16832

169-
When a user re-verifies their email or an admin decides to retry:
33+
When a provider (SES, SendGrid, Twilio) fires an async webhook for a bounce or complaint, your handler should:
17034

171-
```sql
172-
DELETE FROM notification_suppressions
173-
WHERE address = 'dan@example.com' AND channel_type = 'email';
174-
```
35+
1. **Correlate** the webhook payload to the delivery_log row using `provider_message_id` (indexed for O(1) lookup)
36+
2. **Update the delivery_log status** to `bounced` or `complained`, storing the raw webhook payload in `response_payload`
37+
3. **Insert a suppression entry** for hard bounces and complaints (the unique constraint on `(address, channel_type)` makes this idempotent)
38+
4. **Update the channel** — increment `failed_count`, set `last_error` and `last_error_at`
17539

176-
### Bulk export for audit
40+
### Provider-Specific Notes
17741

178-
```sql
179-
COPY (
180-
SELECT address, channel_type, reason, source, provider, created_at
181-
FROM notification_suppressions
182-
ORDER BY created_at
183-
) TO '/tmp/suppressions.csv' WITH CSV HEADER;
184-
```
42+
**Amazon SES:** Configure SNS topics for Bounce and Complaint notification types on your sending identity. The webhook payload contains `mail.messageId` for correlation and `bounce.bounceType` (`Permanent` vs `Transient`) to distinguish hard from soft bounces.
18543

186-
## Channel Auto-Deactivation
44+
**SendGrid:** Enable Event Webhook for `bounce`, `dropped`, `spamreport`, and `delivered` events. The `sg_message_id` field contains the correlation ID (strip the `.filterXXX` suffix). The `spamreport` event maps to the `complained` status.
18745

188-
Each `notification_channels` row tracks consecutive failures:
46+
**Twilio (SMS):** Set a `StatusCallback` URL when sending messages. The `MessageSid` serves as the correlation ID. `undelivered` and `failed` statuses indicate undeliverable phone numbers.
18947

190-
```
191-
failed_count: 0 → 1 → 2 → 3 → is_active = false
192-
```
48+
## Suppression List
19349

194-
The delivery worker should:
195-
1. On failure: increment `failed_count`, set `last_error` and `last_error_at`
196-
2. On success: reset `failed_count = 0`, set `last_used_at = now()`
197-
3. When `failed_count` exceeds threshold (e.g., 3): set `is_active = false`
50+
The `notification_suppressions` table provides address-level blocking. The delivery worker should check this before every send — if an address is suppressed for the given channel type, skip the send entirely.
19851

199-
This is **per-endpoint** deactivation (expired push tokens, revoked webhooks). Address-level suppression (bounced emails) is handled separately by `notification_suppressions`.
52+
### Suppression Reasons
20053

201-
## Monitoring & Troubleshooting
54+
| Reason | When to insert | Source |
55+
|--------|---------------|--------|
56+
| `hard_bounce` | Provider reported a permanent bounce | Auto (webhook handler) |
57+
| `complained` | Recipient reported spam | Auto (webhook handler) |
58+
| `manual` | Admin manually suppressed an address | Manual (admin action) |
59+
| `unsubscribed` | User unsubscribed from all notifications via that channel | Manual (user action) |
20260

203-
### Delivery success rate (last 24h)
61+
### Lifecycle
20462

205-
```sql
206-
SELECT channel_type, status, COUNT(*) AS cnt,
207-
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY channel_type), 1) AS pct
208-
FROM notification_delivery_log
209-
WHERE created_at > now() - interval '24 hours'
210-
GROUP BY channel_type, status
211-
ORDER BY channel_type, cnt DESC;
212-
```
63+
- **Add:** Webhook handler inserts on hard bounce or complaint. The unique constraint on `(address, channel_type)` makes repeated inserts idempotent.
64+
- **Remove:** When a user re-verifies their email or an admin decides to retry, delete the suppression entry.
65+
- The suppression table is in the **private schema** — it's not exposed via GraphQL. Management is done through the delivery worker and admin tooling.
21366

214-
### Complaint rate (should be < 0.1%)
215-
216-
```sql
217-
SELECT
218-
COUNT(*) FILTER (WHERE status = 'complained') AS complaints,
219-
COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')) AS total_sent,
220-
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'complained')
221-
/ NULLIF(COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')), 0), 3) AS complaint_rate_pct
222-
FROM notification_delivery_log
223-
WHERE channel_type = 'email'
224-
AND created_at > now() - interval '7 days';
225-
```
67+
## Channel Auto-Deactivation
22668

227-
### Channels approaching deactivation
69+
Each `notification_channels` row tracks consecutive delivery failures via `failed_count`:
22870

229-
```sql
230-
SELECT id, owner_id, channel_type, endpoint, failed_count, last_error, last_error_at
231-
FROM notification_channels
232-
WHERE failed_count > 0 AND is_active = true
233-
ORDER BY failed_count DESC;
71+
```
72+
failed_count: 0 → 1 → 2 → 3 → is_active = false
23473
```
23574

236-
### Debug a specific delivery
75+
The delivery worker should:
76+
1. **On failure:** increment `failed_count`, set `last_error` and `last_error_at`
77+
2. **On success:** reset `failed_count = 0`, set `last_used_at`
78+
3. **When threshold exceeded** (e.g., 3): set `is_active = false`
23779

238-
```sql
239-
SELECT status, provider, provider_message_id, error, response_payload, attempt_count, sent_at
240-
FROM notification_delivery_log
241-
WHERE notification_id = $1
242-
ORDER BY created_at;
243-
```
80+
This handles **per-endpoint** deactivation (expired push tokens, revoked webhook URLs). Address-level suppression (hard-bounced emails) is a separate concern handled by `notification_suppressions`.
81+
82+
## Key Metrics to Monitor
83+
84+
- **Delivery rate** — percentage of `sent` that reach `delivered`. Below 95% warrants investigation.
85+
- **Bounce rate** — hard bounces as a percentage of total sends. Above 2% risks ISP throttling.
86+
- **Complaint rate**`complained` as a percentage of `delivered`. Above 0.1% triggers ISP penalties and potential account suspension.
87+
- **Channels approaching deactivation** — channels with `failed_count > 0` and `is_active = true` indicate endpoints that may need attention.
Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,33 @@
1-
# Notification Tables — Quick Reference
1+
# Notification Tables
22

3-
Summary of all 6 tables generated by `notifications_module`. For full column listings with types, defaults, indexes, and RLS policies, see the `constructive-db-notifications` skill in constructive-db.
3+
Overview of the 6 tables generated by `notifications_module`. For full column listings with types, defaults, indexes, and RLS policies, see the `constructive-db-notifications` skill in constructive-db.
44

5-
## 1. notifications (public schema)
5+
## Public Tables (GraphQL-exposed, RLS-protected)
66

7-
The notification inbox. One row per notification event.
7+
### notifications
8+
The notification inbox. One row per notification event. Supports single-recipient (`owner_id`), multi-recipient (`recipient_ids` array), and broadcast (`scope_entity_id`) targeting. Includes `category`/`kind` event classification, `priority`, deep-link fields (`resource_type`/`resource_id`/`action_url`), CTA `actions`, and `idempotency_key` for dedup. Optional digest columns (`group_key`, `digest_bucket`, `deliver_after`) are gated by `has_digest_metadata`.
89

9-
**Key columns:** `owner_id` (primary recipient), `recipient_ids` (multi-recipient array), `actor_id` (who triggered it), `category` + `kind` (event classification), `priority`, `topic` (subscriptable resource), `title`, `body`, `image_url`, `data` (jsonb payload), `resource_type` + `resource_id` (deep-link target), `action_url`, `actions` (CTA buttons), `scope_membership_type` + `scope_entity_id` (scope: App/Org/Group), `idempotency_key` (dedup), `expires_at`.
10+
**RLS:** `AuthzComposite` — users see notifications where they're the owner, in the recipient list, or a member of the scoped entity.
1011

11-
**Digest columns** (when `has_digest_metadata = true`): `group_key`, `group_count`, `digest_bucket` (`immediate`/`hourly`/`daily`/`weekly`/`never`), `deliver_after`, `delivered_at`, `delivery_channels`.
12-
13-
**RLS:** `AuthzComposite` — combines `AuthzDirectOwner` (owner_id), `AuthzMemberList` (recipient_ids), and `AuthzEntityMembership` (scope_entity_id).
14-
15-
## 2. notification_read_state (public schema)
16-
17-
Per-user interaction state. Sparse — one row per (notification, user), written lazily on first interaction.
18-
19-
**Key columns:** `notification_id`, `owner_id`, `seen_at`, `read_at`, `dismissed_at`, `snoozed_until`, `delivered_at`, `delivery_channels`.
12+
### notification_read_state
13+
Per-user interaction state. Sparse — one row per (notification, user), written lazily on first interaction. Tracks `seen_at`, `read_at`, `dismissed_at`, `snoozed_until`, and per-user `delivery_channels`.
2014

2115
**RLS:** `AuthzDirectOwner` — users can only see/modify their own read state.
2216

23-
## 3. notification_preferences (public schema) `[has_preferences]`
24-
25-
Per-user channel preferences by category and optional topic.
26-
27-
**Key columns:** `owner_id`, `category`, `topic`, `channel_email`, `channel_push`, `channel_in_app`, `channel_sms`, `channel_webhook`, `is_muted`, `digest_frequency`, `quiet_hours_start`/`end`/`tz`.
17+
### notification_preferences `[has_preferences]`
18+
Per-user channel preferences by `category` and optional `topic`. Boolean toggles for each channel (`channel_email`, `channel_push`, `channel_in_app`, `channel_sms`, `channel_webhook`), plus `is_muted`, `digest_frequency`, and quiet hours (`quiet_hours_start`/`end`/`tz`).
2819

2920
**RLS:** `AuthzDirectOwner` — users manage their own preferences.
3021

31-
## 4. notification_channels (public schema) `[has_channels]`
32-
33-
Registered device and push notification endpoints per user.
34-
35-
**Key columns:** `owner_id`, `channel_type` (web_push/apns/fcm/sms/slack_webhook), `device_name`, `platform`, `endpoint`, `metadata`, `is_active`, `failed_count`, `last_error`, `last_error_at`, `last_used_at`, `expires_at`.
22+
### notification_channels `[has_channels]`
23+
Registered device and push notification endpoints per user. Each row is one delivery target — a push subscription URL, device token, webhook URL, or phone number. Tracks `is_active`, `failed_count` (for auto-deactivation), `last_error`/`last_error_at`, and `expires_at` (push subscription expiry).
3624

3725
**RLS:** `AuthzDirectOwner` — users manage their own channels.
3826

39-
## 5. notification_delivery_log (private schema) `[has_channels]`
40-
41-
Per-attempt delivery audit trail. Not exposed via GraphQL — internal/ops only.
42-
43-
**Key columns:** `notification_id`, `channel_id`, `channel_type`, `provider`, `provider_message_id` (indexed for webhook correlation), `status` (`pending`/`sent`/`delivered`/`failed`/`throttled`/`grouped`/`bounced`/`complained`), `error`, `response_payload`, `attempt_count`, `duration_ms`, `sent_at`.
44-
45-
## 6. notification_suppressions (private schema) `[has_channels]`
46-
47-
Address-level block list. Prevents sending to hard-bounced or complained addresses.
27+
## Private Tables (not exposed via GraphQL, internal/ops only)
4828

49-
**Key columns:** `address` (email, phone, device token), `channel_type`, `reason` (`hard_bounce`/`complained`/`manual`/`unsubscribed`), `source`, `provider`, `metadata`.
29+
### notification_delivery_log `[has_channels]`
30+
Per-attempt delivery audit trail. Tracks `status` (`pending`/`sent`/`delivered`/`failed`/`throttled`/`grouped`/`bounced`/`complained`), `provider`, `provider_message_id` (indexed for webhook correlation), `error`, `response_payload`, `attempt_count`, and `duration_ms`.
5031

51-
**Constraint:** UNIQUE on `(address, channel_type)`.
32+
### notification_suppressions `[has_channels]`
33+
Address-level block list. Prevents sending to hard-bounced or complained addresses. Keyed on `(address, channel_type)` with a unique constraint. Tracks `reason` (`hard_bounce`/`complained`/`manual`/`unsubscribed`), `source`, `provider`, and `metadata` for audit.

0 commit comments

Comments
 (0)