9.8 KiB
9.8 KiB
Tasks — Suppression completion
Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
0. Pre-flight (non-negotiable)
- 0.1 Confirm clean working tree (
git statusreports nothing) and no in-flight worktrees with uncommitted work (git worktree listthengit statusin each) - 0.2 If any worktrees exist under
.claude/worktrees/, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (git worktree remove) before opening a new one — code that has been lost twice on main is the failure mode this guards against. - 0.3 Confirm the package set in
MANIFEST.mdmatchesfind packages -maxdepth 3 -name package.jsoncount. Drift here means a prior change archived without regenerating the manifest.
Audit Note (2026-05-02)
Status: DEFERRED. packages/emails/suppression/src/core/ only contains exporter and importer — none of bounce-classifier, suppression-filter, suppression-manager, or unsubscribe-link exist. Multi-channel ChannelType and the BOUNCE_CLASSIFICATION_FAILED / UNSUBSCRIBE_* error codes are also absent. 0% of work is done.
1. Spec authoring
- 1.1 Read
../suaveplan/enterprise/suppression.mdend-to-end - 1.2 Read
openspec/specs/suppression/spec.md(canonical capability spec; this change introduces it if absent) - 1.3 Read
openspec/AGENTS.mdSections 13, 13a, and 14 - 1.4 Draft
proposal.mdfromopenspec/templates/proposal.md - 1.5 Fill
## Source mappingtable with upstream line ranges - 1.6 Draft
specs/suppression/spec.mdfromopenspec/templates/spec-delta.md - 1.7 Every requirement carries a
**Verified by:**line - 1.8
openspec change validate suppression-completionexits with no errors
2. Implementation — BounceClassifier
- 2.1 Port
BounceClassifierfrompackages/universal/suppression/src/core/bounce-classifier/bounce-classifier.ts(L1-L167) intopackages/emails/suppression/src/core/bounce-classifier/bounce-classifier.ts - 2.2 Default rule set: RFC 3463 enhanced status
5.x.x→ hard,4.x.x→ soft (verbatim from L84-L160) - 2.3 Message-pattern fallbacks for Gmail, Outlook, SendGrid (verbatim from upstream defaults)
- 2.4 Add
addRule(rule),removeRule(name),clearRules(),classify({ enhancedStatus, replyCode, message })methods - 2.5 Throw
SuppressionErrorwith codeBOUNCE_CLASSIFICATION_FAILEDif input fails Zod validation - 2.6 Sibling
.md(≥200 words, basic + advanced examples)
3. Implementation — SuppressionFilter
- 3.1 Port
SuppressionFilterfrompackages/universal/suppression/src/core/suppression-filter/suppression-filter.ts(L1-L126) intopackages/emails/suppression/src/core/suppression-filter/suppression-filter.ts - 3.2 Filter API:
filter(contacts: Contact[], channel: ChannelType): Contact[] - 3.3 Honour include / exclude / domain-block override semantics from upstream
- 3.4 Throw
SuppressionErrorwith codeFILTER_INVALID_CHANNELif channel is unknown - 3.5 Sibling
.md
4. Implementation — SuppressionManager
- 4.1 Port
SuppressionManagerfrompackages/universal/suppression/src/core/suppression-manager/suppression-manager.ts(L1-L191) intopackages/emails/suppression/src/core/suppression-manager/suppression-manager.ts - 4.2 Replace
crypto.randomUUID(upstream L13) with injectableIdGeneratorfrom@suaveplan/types - 4.3 Replace
Date.now()with injectableClock - 4.4 Public API:
recordBounce(contact, bounceInfo),recordComplaint(contact, reason),recordUnsubscribe(contact, source),filter(contacts, channel),generateUnsubscribeUrl(contact, baseUrl),verifyUnsubscribeUrl(url) - 4.5 Emit events
'bounce:hard','bounce:soft','complaint','unsubscribe','filter:applied'(extendsEventEmitter) - 4.6 Sibling
.md
5. Implementation — UnsubscribeLinkGenerator
- 5.1 Port
UnsubscribeLinkGeneratorfrompackages/universal/suppression/src/core/unsubscribe-link/unsubscribe-link.ts(L1-L99) intopackages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.ts - 5.2
generateUnsubscribeUrl(contact, baseUrl, secret, options?)— produce URL?u=<contactId>&c=<channel>&t=<timestamp>&s=<sig>with HMAC-SHA256 signature - 5.3
verifyUnsubscribeUrl(url, secret, options?)— returnUnsubscribePayloadon valid signature; throwSuppressionErrorUNSUBSCRIBE_INVALID_SIGNATURE/UNSUBSCRIBE_EXPIRED/UNSUBSCRIBE_MISSING_PARAMon failure - 5.4 Configurable TTL (default 30 days); use injectable
Clockfornow()and TTL comparison - 5.5 Sibling
.md
6. Multi-channel support
- 6.1 Extend
SuppressionEntryinpackages/emails/suppression/src/types/suppression.types.tswithchannel: ChannelTypefield - 6.2 Add
ChannelType = 'email' | 'sms' | 'push'type - 6.3 Add
Contact = { id: string, channel: ChannelType, identifier: string }type - 6.4 Extend
SuppressionStorewithfindByChannel(channel, identifier)method - 6.5 Existing
find(email)delegates tofindByChannel("email", email) - 6.6 JSON / CSV import paths default to
channel: "email"for entries without an explicit channel field (back-compat)
7. Error codes
- 7.1 Extend
SUPPRESSION_CODESinpackages/emails/suppression/src/errors/suppression-error.tswith:BOUNCE_CLASSIFICATION_FAILED,UNSUBSCRIBE_INVALID_SIGNATURE,UNSUBSCRIBE_EXPIRED,UNSUBSCRIBE_MISSING_PARAM,FILTER_INVALID_CHANNEL - 7.2 Update
errors/suppression-error.mdto document each new code
8. Types and Zod schemas
- 8.1 Add
BounceInfo,BounceClassification,BounceRule,UnsubscribePayload,SuppressionFilterOptions,SuppressionManagerOptions,UnsubscribeLinkOptionstotypes/suppression.types.ts - 8.2 Add Zod schemas
BounceInfoSchema,ContactSchema,UnsubscribePayloadSchematoschemas/suppression.schema.ts - 8.3 Sibling
.mdfor the schema module
9. Wiring
- 9.1 Update
packages/emails/suppression/src/index.tsto re-export the four new modules and their types - 9.2 Add subpath exports
./core/bounce-classifier/bounce-classifier,./core/suppression-filter/suppression-filter,./core/suppression-manager/suppression-manager,./core/unsubscribe-link/unsubscribe-linktopackages/emails/suppression/package.json - 9.3 Update root
README.md## Modulessection
10. Tests
- 10.1
packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts—5.1.1→ hard,4.4.7→ soft, message-pattern fallback for Gmail bounce; custom rule registration;BOUNCE_CLASSIFICATION_FAILEDon invalid input - 10.2
packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts— multi-channel filter (email vs sms vs push); include / exclude lists; domain block;FILTER_INVALID_CHANNELon unknown channel - 10.3
packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts—recordBounce(hard) emitsbounce:hardevent AND adds to store;recordComplaintemitscomplaint;recordUnsubscribeemitsunsubscribe;filteremitsfilter:applied;generateUnsubscribeUrlround-trips throughverifyUnsubscribeUrl - 10.4
packages/emails/suppression/src/core/suppression-manager/suppression-manager.spec-conformance.test.ts— injected Clock / IdGenerator deterministic; OTelsuppression.manager.record_totalcounter; injected store survives interface contract - 10.5
packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts— generate / verify round-trip; tampered-signature →UNSUBSCRIBE_INVALID_SIGNATURE; expired TTL →UNSUBSCRIBE_EXPIRED; missingsparam →UNSUBSCRIBE_MISSING_PARAM - 10.6 Every
it(...)carries{ timeout: 5000 }
11. Documentation
- 11.1 Sibling
.mdfor every new implementation.ts(≥200 words, basic + advanced examples, full API reference) - 11.2 README links new
.mds under## Modules - 11.3 Add
## Migrationsection toREADME.mdcovering the back-compat defaultchannel: "email"for legacy import paths
12. Pre-archive verification (the 5-gate triple)
- 12.1
bun run check— Biome 0/0/0 across the monorepo - 12.2
bunx turbo lint typecheck test --filter=@suaveplan/suppression— green - 12.3
bunx turbo test:coverage --filter=@suaveplan/suppression— 100/100/100/100 - 12.4
bun scripts/openspec-conformance.ts @suaveplan/suppression— exit 0, noerrorfindings - 12.5
bunx turbo build --filter=@suaveplan/suppression— green
13. Pre-archive 7-row checklist (AGENTS.md Section 13)
- 13.1 Generics threaded;
expectTypeOfonChannelTypeandBounceClassificationliteral unions - 13.2 Zod schemas wired at every public boundary (
BounceInfoSchema,ContactSchema,UnsubscribePayloadSchema,SuppressionManagerOptionsSchema) - 13.3 Every new error code thrown from at least one production path
- 13.4 Strict-mode tested: TTL boundary cases (exactly at expiry, one second before, one second after)
- 13.5 OTel emitted:
suppression.manager.record_total(tagged by event type),suppression.filter.applied_total(tagged by channel),suppression.classifier.classify_duration_ms; asserted with a real in-memoryMeterProvider - 13.6 Lifecycle dedup:
recordBouncefor the same(channel, identifier)is idempotent — second call does NOT add a duplicate store entry; emits an'idempotent'debug event instead - 13.7 No retroactive tick — every box reflects work done in this change
14. Archive
- 14.1 Fold spec delta into
openspec/specs/suppression/spec.md(creating it if absent) - 14.2 Move
openspec/changes/suppression-completion/→openspec/archive/<yyyy-mm-dd>-suppression-completion/ - 14.3 Refresh
MANIFEST.md