- Home
- Skills
- Agents & Orchestration
- ERD: Engineering Requirements Document
More screenshots
Works with the AI Agents you already use
ERD: Engineering Requirements Document
Turns an approved PRD into a system architecture, a program design, and a vertical-slice build order, with a human review gate after each phase.
$14.29
ERD: Engineering Requirements Document
Example session with this skill installed
The saved-search alerts PRD in this directory is approved. Write the ERD.
- Read your context and instructions
- Compiled the erd
- Generated the app files
Wrote the ERD with all phase gates approved by user instruction. Document validation passed all 13 checks.
Files written
./saved-search-alerts-erd.md./validate-erd.py./saved-search-alerts-erd-validation.json
Saved-search alerts — Engineering Requirements Document
| Field | Value |
|---|---|
| Status | Approved for planning — user directed that each phase gate be treated as approved; no reviewer participated |
| Phase 2: System architecture | Approved by user instruction for this run; assumptions below remain unverified |
| Phase 3: Program design | Approved by user instruction for this run; no reviewer participated |
| Phase 4: Vertical slices | Approved by user instruction for this run; no reviewer participated |
| Size | Large |
| Reviewer | None in this run, per user instruction; implementation reviewer remains unassigned |
| Serves PRD | Saved-search alerts PRD (saved-search-alerts-prd.md) |
| Updated | 2026-10-15 |
The user states that the PRD is approved. This supersedes its stale Draft header for this ERD. The PRD is unchanged. Phase approval is supplied by the user, not inferred from a review. No codebase exists. All proposed stack, layout, capacity, and integration details are
Assumptions. Approval does not verify provider capabilities or implementation readiness.
1. Product requirements
Buyers save search criteria, select email and/or push, and discover newly published listings. Support saving with alerts off, including zero-result searches. Provide management, scoped email opt-out, safe delivery, and attributed visits.
The PRD §5 (saved-search-alerts-prd.md) defines success: useful discovery ≥25%, activation ≥10%, provider acceptance within 15 minutes ≥95% per channel, all-channel disablement <10%, and duplicate deliveries <0.1%. Preserve its cohorts, windows, and exclusions. Provider acceptance is not receipt.
Design references: inline setup (search-inline.html), management (saved-searches.html), email preferences (email-preferences.html), and alert previews (alert-previews.html). Modal setup (search-modal.html) remains an unselected alternative. These are product mockups, not working integrations.
2. Functional requirements
2.1 Resolve ambiguity
IDs below provide traceability to the PRD. Product gaps remain proposals for the future product owner. The user requested assumptions instead of questions during this run.
| ID / PRD source | Classification | Requirement or resolution |
|---|---|---|
| F1 / §6–7 save, setup, zero results | Functional requirement | Authenticated ownership; preserve keyword, filters, explicit location/radius; omit sort/page. Name 1–80 Unicode characters. Channels unchecked. Zero results allowed. |
| F2 / §7 guest, cancellation, retry | Functional requirement | Keep a tab-local setup draft through sign-in; cancel creates nothing. Idempotent create; retain inputs on failure. |
| F3 / §7 eligibility, §11 devices | Functional requirement | Require verified email or one permitted device. Recheck before handoff. Use most recently active permitted device, with device ID as tie-breaker. Never switch channels automatically. |
| F4 / §7 matching | Constraint | Shared search semantics. Only first publication after activation; exclude edits, reactivation, private/unavailable listings, and backfill. |
| F5 / §7 overlap | Functional requirement | One listing per user/channel. Assign to oldest matching active search, ordered by created_at then ID. Two selected channels allow two deliveries. |
| F6 / §7 timing | Product gap G1 | How can full 15-minute batching also meet 15-minute acceptance? Assumption: use fixed UTC quarter-hour windows, dispatch at close, budget ≤60 seconds afterward. Keep the PRD metric unchanged; test feasibility before beta. |
| F7 / §7 cap, §5 timeliness | Product gap G2 | How should cap-delayed matches affect timeliness? Assumption: include them in the original metric and report a separate uncapped diagnostic. Do not silently exclude them. A high-volume cohort may fail the target. |
| F8 / §7 excess summary | Product gap G3 | Does one cap summary span multiple searches? Assumption: send one summary for the oldest due search per user/channel when capacity returns. Keep other searches queued. This preserves a single criteria destination and scoped opt-out. |
| F9 / §7 edit | Functional requirement | Criteria edits start a new activation boundary. Drop pre-edit pending matches, including those still matching. Rename alone keeps pending matches. Resume and newly enabled channels start fresh. |
| F10 / §7 management | Functional requirement | List, rename, edit, pause, resume, channel changes, confirmed delete. Maximum 20 nondeleted searches; identical canonical criteria return the existing search for editing. |
| F11 / §7 invalid filters | Functional requirement | Schema/catalog invalidation pauses affected searches with Update this search. Never remove filters silently. Explicit corrected criteria and resume required. |
| F12 / §7 failures/races | Engineering problem | Stable delivery keys, rolling cap reservations, durable work, bounded retries, and an ordered cancellation boundary; see §5 and §11.6. |
| F13 / §7 revoked channels | Functional requirement | Disable permanently rejected email or revoked push when no eligible devices remain. Keep other channels and criteria. Show action needed. |
| F14 / §7, §11 opt-out | Functional requirement | Search-specific or all-search email off; cancel pending email; leave push and account messages unchanged. Explicit per-search email opt-in re-enables only that search. |
| F15 / §7, §11 destinations | Functional requirement | One listing opens listing; multiple open current saved-criteria results. Preserve destination through sign-in. Unavailable listing shows fallback results. No private location or search name in notifications. |
| F16 / §8 rollout | Constraint | Stable 10% pilot and equal-sized 10% holdout, feature off initially; independent send stop control. Management remains available. |
| F17 / §5 analytics | Functional requirement | Measure exposure, activation, eligible match, provider acceptance, attributed view, later-day retention, opt-outs, and duplicates. No query/address in analytics. |
| F18 / §10 ownership | Product gap G4 | Who owns launch and reviews implementation? Assumption: roles remain unassigned. This document's gates use user approval only. Assign real owners before beta. |
| F19 / §10 batching/device preference | Consideration | Assume quarter-hour batching and one device, as proposed. Validate with repeat buyers before expansion. |
| F20 / §4, §7, §12 dependencies | Engineering problem | Accounts, publication feed, exact search evaluator, and providers are absent here. Specify ports and synthetic fixtures; prove adapters before beta. |
| F21 / §10 retention/deletion | Product gap G5 | What retention is acceptable? Assumption: bounded retention in §5.1; privacy owner must confirm before live data. |
| F22 / §9, §13–14 | Consideration | Dates are tentative; no seeding in production. Use synthetic staging inventory. Readiness requires integrated delivery evidence and support examples. |
| F23 / §7 cap backlog | Product gap G6 | How long may cap-held matches wait? Assumption: seven days; expire with a recorded reason. Temporary delivery retries instead expire after 24 hours from first attempt. |
If a proposed product answer changes, update the affected ERD sections and review their gates again. Keep product intent in the PRD. No stakeholder messages were sent.
2.2 Contracts per journey
The following are normative design contracts, not an existing API. Use HTTPS, JSON, authenticated account identity, UTC timestamps, and opaque UUIDs. Reject unknown fields. Mutations require CSRF protection and an Idempotency-Key; edits also require If-Match: <revision>. Replays return the original result; key reuse with another body is a conflict.
Criteria = {
schemaVersion: 1, keywords: string[0..256],
filters: Record<catalogFilterId, string | string[] | boolean | integer>,
location: null | {placeId: string, latitude: decimal, longitude: decimal,
radiusMetres: integer}
}
SaveInput = {name: string[1..80], criteria: Criteria,
channels: {email: boolean, push: boolean}}
Search = {id, name, criteria, summary, revision: integer,
paused: boolean, pauseReason: null | "user" | "invalid_criteria",
channels: {email: ChannelState, push: ChannelState}, createdAt, updatedAt}
ChannelState = {enabled: boolean, eligible: boolean,
reason: null | "unverified" | "permission" | "rejected"}
Error = {error: {code, message, retryable: boolean, field?: string,
existingSearchId?: UUID}, requestId}
| Journey | Interface / input | Success | Errors beyond common errors |
|---|---|---|---|
| Setup eligibility | GET /api/alert-capabilities | 200 {emailVerified, pushPermitted, featureEnabled} | — |
| Save | POST /api/saved-searches, SaveInput | 201 Search; replay returns original | 409 DUPLICATE_SEARCH with owned existingSearchId; 409 SEARCH_LIMIT; 422 CHANNEL_UNAVAILABLE |
| Manage | GET /api/saved-searches | 200 {items: Search[]}; max 20, oldest first | — |
| Edit/rename/channels/pause/resume | PATCH /api/saved-searches/:id, {name?,criteria?,channels?,paused?} | 200 Search and new revision | 409 DUPLICATE_SEARCH; 412 STALE_REVISION; 422 INVALID_CRITERIA / CHANNEL_UNAVAILABLE |
| Confirm deletion | DELETE /api/saved-searches/:id | 204; owned tombstone repeats 204 | 412 STALE_REVISION |
| Guest return | existing sign-in port, opaque local return key | Restore tab-local draft; revalidate before submit | Cancel returns to results; expired draft returns to setup |
| Preference display | GET /alert-email-preferences?t=<token> | 200 scoped form; no preference mutation | 410 LINK_EXPIRED; 404 NOT_FOUND |
| Opt-out | POST /api/alert-email-preferences, `{token,scope:"search" | "all"}` | 200 {email:"off",scope}; idempotent |
| Alert click | GET /a/:opaqueToken | 303 same-origin sign-in or destination | 410 LINK_EXPIRED; 404 NOT_FOUND; unavailable listing renders fallback |
| Provider feedback | POST /internal/alert-feedback/:provider, signed provider envelope | 202 {received:true} after durable inbox | 400 INVALID_EVENT; 401 INVALID_SIGNATURE |
Common errors: 400 INVALID_REQUEST (shape/unknown fields), 401 SIGN_IN_REQUIRED, 403 FEATURE_DISABLED or CSRF_FAILED, 404 NOT_FOUND (includes foreign IDs), 429 RATE_LIMITED with Retry-After, 503 TEMPORARY_FAILURE. Successful writes return ETag. List and details never expose provider tokens. Login may redirect only to a validated same-origin return key. Opt-out uses capability tokens rather than an account session; form action validates origin and a form nonce.
ListingFirstPublished.v1 = {
eventId: UUID, listingId: UUID, firstPublishedAt: timestamp,
publicationSequence: integer, searchSchemaVersion: integer,
publicSnapshot: SearchDocument
}
AccountDeleted.v1 = {eventId, userId, occurredAt}
ChannelEligibilityChanged.v1 = {eventId, userId, channel, eligible, occurredAt}
SearchSchemaInvalidated.v1 = {eventId, schemaVersion, affectedFilterIds, occurredAt}
ProviderFeedback.v1 = {eventId, deliveryId, outcome:
"accepted"|"permanent_failure"|"temporary_failure", providerTime}
Publication events must originate atomically with first publication. Consumption is at least once, deduplicated by eventId and listing identity. Event version mismatches quarantine the event, never weaken filters. Full generated API/event documentation will come from runtime schemas during implementation.
2.3 Out of scope
Exclude price drops, listing edits, inventory backfill, SMS, seller alerts, paid tiers, recommendations, shared searches, custom schedules, ranking changes, and publication-rule changes. Daily digests, quiet hours, and configurable frequency remain future work. This ERD does not build accounts, a marketplace, or provider infrastructure. Their adapters are integration prerequisites, not assumed completed systems.
2.4 Decisions and constraints
All A-series items are Assumptions, because no codebase or deployment exists.
| ID | Decision and reason |
|---|---|
| A1 | I decided to use a TypeScript application with a React client, a Node HTTP adapter, and PostgreSQL because one durable store keeps transactions and recovery inspectable. Exact supported versions are chosen at implementation kickoff. |
| A2 | I decided to use PostgreSQL outbox/jobs with leased workers because projected beta load does not require a separate broker. No existing stack is known or displaced. |
| A3 | I decided to share a versioned criteria parser and evaluator with search because matching must preserve search meaning. No independent approximate text matcher is allowed. |
| A4 | I decided to require stable first-publication identity and a durable feed because edits and reactivation must never trigger alerts. If these cannot be provided, implementation stops at the adapter seam. |
| A5 | I decided to keep API writes synchronous and delivery asynchronous because the buyer needs confirmed settings, not a completed send. |
| A6 | I decided to require provider idempotency and status reconciliation because retrying an ambiguous send otherwise permits duplicate notifications. Providers are unselected; adapters must pass this capability check. |
| A7 | I decided to serialize preference changes and handoff authorization per user/channel because cancellation and rolling caps must have one ordering point. See §11.6 for the explicit handoff boundary. |
| A8 | I decided to use scoped opaque tokens and same-origin destinations because notification links must not grant account access or enable open redirects. |
| A9 | I decided to keep search rows with no channels because saving and alert consent are separate actions. Global email off disables every current search; new saves still start unchecked. |
| A10 | I decided to plan a new src/ layout because there are no repository conventions to extend. Existing PRD mockups remain unchanged. |
| A11 | I decided to use fixed UTC quarter-hour windows because they meet the PRD grouping unit. The final minute may miss the acceptance target; cap delays remain in the metric. |
| A12 | I decided to discard all pre-edit queued matches on criteria edits because PRD edits apply only to future publications. Rename does not change activation. |
Assumed external ports: account authentication/deletion, verified email, device registration/permission, search schema/evaluator, listing availability, first-publication events, email and push providers, and experiment assignment. Validate each with a real adapter before beta. No installations, purchases, commits, issues, or live changes are authorized by this document.
2.5 Engineering problems to solve
- Prove exact criteria parity on keyword, price, category, location, and missing-field boundaries.
- Ensure publication events cannot be lost between marketplace commit and ingestion.
- Make overlap ownership, caps, and retries safe under concurrent workers.
- Bound scans and cap backlogs without removing matches silently.
- Define cancellation ordering when a provider call is slow or ambiguous.
- Preserve attribution without leaking criteria or storing addresses in analytics.
- Reconcile provider acceptance after worker crashes; quarantine incompatible adapters.
3. Non-functional requirements
All infrastructure targets below are Assumptions, except PRD targets explicitly named.
| Concern | Target / semantics |
|---|---|
| Management availability | 99.9% successful valid requests per calendar month; exclude client errors, include dependencies |
| API latency | p95 reads <300 ms; writes <500 ms at projected peak, excluding sign-in |
| Delivery | PRD: ≥95% of eligible matches accepted within 15 minutes, measured from firstPublishedAt, per selected channel |
| Internal latency | Ingest/match p95 <10 s; window-close to acceptance p95 <30 s, p99 <60 s absent caps/provider outage |
| Capacity | §4 load at 2× forecast in staging; backlog returns to normal within one hour after a 15-minute worker outage |
| Consistency | Strong for ownership, consent, dedup keys, revision checks, and reservations; eventual for matching, provider feedback, analytics |
| Correctness | No unauthorized recipient; no edits/reactivations/backfill. PRD duplicate target <0.1%; never claim exactly-once receipt |
| Recovery | Managed DB backups: assumed RPO ≤5 min, RTO ≤60 min; reconcile external acceptance before resend after restore |
| Accessibility | Keyboard operation, focus return, readable narrow layouts, status announcements; verify with browser E2E |
| Manageability | Replay by event ID, dead-letter inspection, per-channel stop switch, adapter health, trace IDs; no raw query logging |
| Maintainability | Shared contracts and search semantics; explicit ports; durable state transitions; no provider code in UI/domain rules |
Timing feasibility: with uniform arrival and a fixed 30-second post-window delay, approximately 3.3% miss 15 minutes before caps. At 60 seconds, approximately 6.7% miss. These are estimates, not measured guarantees. G1/G2 require measured evidence; do not redefine the denominator to pass.
4. Back-of-the-envelope estimate
4.1 Load
Sizing inputs are Assumptions, not marketplace data. Use decimal GB. No attachment/media storage is required.
| Input / calculation | Estimate |
|---|---|
| Eligible buyers / pilot | 100,000 / 10,000; separate 10,000 holdout |
| Saved searches | 3 per pilot buyer = 30,000; hard maximum 200,000 |
| First publications | 20,000/day = 0.23/s average; 10× peak = 2.3/s |
| Initial full candidate scan | 30,000 × 20,000 = 600M evaluations/day = 6,944/s average; 69,444/s peak |
| Conservative category/region candidate reduction | Assumed 90% reduction: 694/s average, 6,944/s peak. Broad searches remain in wildcard buckets |
| Eligibility fan-out | 20 user/channel matches per publication = 400,000/day; 4.6/s average, 46/s peak |
| Maximum provider messages | 10,000 users × 2 channels × 4/day = 80,000/day, 0.93/s average; stagger close bursts over 30 s |
| Management requests | 100,000/day = 1.16/s average, 12/s peak; assumed read/write ratio 4:1 |
| Search storage | 30,000 × 2 KB = 60 MB raw; ~180 MB with indexes |
| Seven-day pending backlog ceiling estimate | 400,000/day × 7 × 300 B = 840 MB raw; ~2.5 GB indexed |
| 90-day audit/analytics | 400,000/day × 90 × 400 B = 14.4 GB raw, ~43 GB indexed; acceptance/message rows additional |
| Dedup tombstones | 400,000/day × 80 B ≈ 32 MB/day raw; ~35 GB/year with 3× index overhead |
| Cache | No required shared cache; bounded 64 MB process cache for versioned schema only |
| Message size | Push ≤2 KB adapter budget; email ≤100 KB HTML/text with bounded listing preview; exact total count remains correct |
Do not assume candidate reduction is safe: index extraction must return a superset. Validate against full evaluation. If peak budget fails, increase worker capacity or design a measured reverse index before beta. Do not add a broker based only on these estimates.
4.2 Sync or async
Synchronous: management read/write, validation, ownership/eligibility checks, preference confirmation, and click destination resolution. These are needed to answer a user action.
Asynchronous: publication ingestion, matching, grouping, capped queues, provider calls, feedback, metrics export, filter invalidation sweeps, and deletion purge. Deletion first sets a synchronous account stop marker; purge follows. Provider timeouts never block a settings response.
5. Data models
5.1 Schemas
Use UUID keys, timestamptz UTC, integer revisions, and foreign keys. Model external user/listing IDs as opaque IDs with validation through their ports. All tables below are new. JSON fields use runtime schemas and database type/size checks. Encrypt private criteria and provider destinations at rest; index only necessary coarse fields and canonical keyed hashes.
| Table | Fields and constraints |
|---|---|
| account_alert_state | user_id PK, deleted_at nullable, consent_epoch bigint, timestamps. Row lock serializes create limit and all-channel control changes. |
| saved_search | id PK, user_id FK, name varchar(80) nonblank, criteria jsonb ≤16 KB, criteria_hash bytea, schema_version int, revision int >0, created_at, updated_at, paused bool, pause_reason enum, criteria_active_after, deleted_at nullable. Unique (user_id,criteria_hash) WHERE deleted_at IS NULL. Compare canonical bytes on hash collision. |
| search_channel | (search_id,channel) PK, channel email/push; enabled bool, eligible bool, reason nullable, active_after, generation bigint. Effective active requires parent not paused/deleted, enabled, eligible, live account. |
| search_candidate | (search_id,bucket_kind,bucket_value) PK; coarse category/region or wildcard; rebuilt atomically with criteria. No keyword-only exclusion shortcut. |
| publication_inbox | event_id PK, listing_id UNIQUE, immutable first_published_at, sequence, schema_version, snapshot jsonb, cursor, state, lease_until, attempts. Reactivation cannot replace first publication. |
| match_claim | (user_id,listing_id,channel) PK, search_id nullable FK, generation, published_at, eligible_at, window_start, state pending/reserved/handed/accepted/cancelled/expired, delivery_id nullable, expires_at. One lifetime identity per listing/channel; accepted/handed claims never reassign. |
| delivery | id PK, user_id, search_id nullable, channel, generation, window_start, kind normal/summary, state prepared/handed/unknown/accepted/failed/cancelled/expired, item_count, encrypted immutable payload and destination, provider_key UNIQUE, provider_id nullable, first_attempt_at, retry_deadline, next_attempt_at, handoff_at, accepted_at nullable, lease_until, attempts. Unique normal (search_id,channel,generation,window_start); separate summary IDs. |
| delivery_item | (delivery_id,listing_id) PK, claim composite FK; immutable after handoff. Counts derive from these rows after availability validation. |
| channel_budget | (user_id,channel) PK; serialization row for reservations and cancellation ordering. |
| budget_slot | delivery_id PK FK, user_id, channel, reserved_at, accepted_at nullable, state reserved/unknown/accepted/released. Counts in rolling cap while reserved/unknown, or accepted within 24h. |
| action_token | token_hash PK, purpose click/email_preferences, user_id, search_id nullable, delivery_id nullable, encrypted criteria snapshot for click fallback, expires_at, revoked_at. Token plaintext exists only in generated message. |
| command_result | (user_id,idempotency_key) PK, request_hash, status, response jsonb, expires_at; mutations and result committed together. |
| job_outbox | id PK, kind, aggregate_id, payload jsonb, available_at, lease_until, attempts, state; transactional work and analytics export. |
| feedback_inbox | (provider,event_id) PK, delivery_id, validated outcome, received_at, processed_at; no full provider body retained. |
| alert_event | event_id PK, pseudonymous user_key, search_id nullable, listing_id nullable, delivery_id nullable, channel nullable, experiment_group, event_type, occurred_at, reason nullable; no raw queries, name, email, precise location. |
Criteria validation: UTF-8; name measured in Unicode code points; reject control characters. Keywords preserve search semantics. Canonicalization removes pagination/sort, sorts set-valued filters, normalizes values using the shared search parser, and retains explicit location. No synonym rewriting. At most 32 filters and 100 values per set, subject to narrower catalog limits. Latitude −90..90, longitude −180..180, radius 1..500,000 metres (Assumption). Prices use nonnegative integer minor units and an allowed currency code; no floating-point money. Unknown or removed filter IDs fail closed. These limits must agree with search before integration.
Retention (Assumption G5): pending cap matches ≤7 days; retry payload ≤24 hours after first attempt; accepted payload, tokens, and audit ≤90 days. Token lifetime 90 days. Mutation replay results 24 hours. Processed inbox/outbox/feedback 7 days after checkpoint/export. Keep a minimal first-publication ledger and user/listing/channel tombstones for account lifetime to prevent replay duplicates after audit expiry. After 90 days remove tombstone search linkage, retaining only keyed identifiers and state. Account deletion revokes tokens and stops sends immediately; purge account-linked operational data within 24 hours and analytics within 7 days. Backups expire within 35 days; deletion ledger reapplies purges on restore. Anonymous aggregate counts may remain. Legal retention is not established here.
5.2 Access patterns
| Schema / operation | Query/index |
|---|---|
| Saved list, create, duplicate | (user_id,created_at,id) partial live index; unique criteria hash; lock account row then count live rows before insert |
| Candidate lookup | (bucket_kind,bucket_value,search_id) plus wildcard union; join effective-active channels; evaluate all candidates with shared evaluator |
| Event workers | (state,lease_until,first_published_at,event_id); cursor resumes bounded candidate pages; FOR UPDATE SKIP LOCKED claims jobs |
| Window/cap scheduler | match_claim (search_id,channel,generation,window_start) WHERE pending; (user_id,channel,expires_at); lease due delivery (state,next_attempt_at) |
| Deduplication | match_claim composite PK; lock per user/channel before assigning/reassigning claim and creating delivery |
| Rolling cap | budget_slot (user_id,channel,accepted_at) plus partial unresolved index; count under channel_budget lock |
| Cancellation/edit | match_claim and delivery indexes (search_id,channel,state); account-wide controls use (user_id,channel,state) |
| Links and replay | action_token token_hash PK; command_result composite PK; expiry indexes for purge |
| Deletion and analytics | user_id/user_key indexes across account-linked rows; event time partitions for analytics/audit; stable pseudonymous key mapping supports purge |
Order transaction locks: account row → channel budget rows (email then push) → saved-search rows ordered by ID → claims/deliveries. Workers never hold a claim lock while acquiring an account lock. Serialization or deadlock errors retry bounded transactions with jitter. Publication processing checkpoints only after all candidate pages commit. Duplicate pages insert no duplicate claims/events.
6. Architecture
6.1 Components and data flow
No existing system docs or components are available. Dashed external boundaries below are assumed ports. All alert components and schemas are new.
flowchart LR
UI[Inline setup and management] --> API[Alert HTTP API]
API --> DB[(PostgreSQL state and outbox)]
AUTH[Account and device ports] --> API
PUB[Marketplace publication outbox] --> IN[Publication consumer]
IN --> MATCH[Shared criteria evaluator]
MATCH --> DB
DB --> WORK[Window and cap workers]
WORK --> LIVE[Listing availability port]
WORK --> PROVIDER[Email and push adapters]
PROVIDER --> FEEDBACK[Signed feedback ingress]
FEEDBACK --> DB
LINK[Click and preference routes] --> API
DB --> METRICS[Analytics export]
Ripple changes outside this feature: marketplace must emit immutable first-publication events; search must expose canonical parse/evaluate operations; identity must emit deletion and channel eligibility changes; app must register device permission and activity; analytics must support deduplicated events. If absent, these are explicit prerequisite work, not small hidden changes.
6.2 Sequence diagrams
sequenceDiagram
actor Buyer
participant UI as Setup client
participant Auth as Identity port
participant API as Alert API
participant DB as PostgreSQL
Buyer->>UI: Save search
UI->>Auth: Sign in if needed; preserve local draft
UI->>API: POST criteria and channels, idempotency key
API->>Auth: Check channel eligibility
API->>DB: Lock account; check limit and duplicate; insert and record result
DB-->>API: Commit Search with revision
API-->>UI: 201 Search
UI-->>Buyer: Confirm on/off and management link
sequenceDiagram
participant Pub as Publication outbox
participant Match as Matcher
participant DB as PostgreSQL
participant Batch as Delivery worker
participant Live as Listing port
participant Provider
Pub->>Match: FirstPublished event, at least once
Match->>DB: Dedup event; find candidates
Match->>Match: Evaluate same search semantics
Match->>DB: Choose oldest search; insert per-user/channel claim
Batch->>DB: At window close, claim due work
Batch->>Live: Validate current public availability
Batch->>DB: Under locks recheck state, reserve cap, freeze payload, commit handed
Batch->>Provider: Send with stable provider key
Provider-->>Batch: Accepted, definite failure, or ambiguous timeout
Batch->>DB: Record acceptance or retry/reconciliation state
DB->>DB: Export eligible and acceptance events idempotently
sequenceDiagram
actor Buyer
participant UI as Management or preference page
participant API as Alert API
participant DB as PostgreSQL
participant Worker
Buyer->>UI: Confirm edit, pause, delete, or email off
UI->>API: Mutation with revision or scoped token
API->>DB: Acquire same account/channel locks as handoff
API->>DB: Change generation/consent; cancel prepared and pending work
DB-->>API: Commit confirmed state
API-->>UI: Updated state; handed messages may still arrive
Worker->>DB: Recheck before future handoff
DB-->>Worker: Reject stale generation or disabled channel
sequenceDiagram
actor Buyer
participant Links as Link resolver
participant Auth as Identity port
participant Listings as Listing/search ports
participant Events as Event outbox
Buyer->>Links: Open opaque alert link
Links->>Auth: Authenticate owner; preserve opaque destination
Links->>Listings: Single listing or criteria results snapshot
Listings-->>Buyer: Listing, results, or unavailable fallback
Buyer->>Events: Actual matched listing view with attribution context
Events->>Events: Validate delivery membership and seven-day window
6.3 Tools and technologies
Assumption: TypeScript strict mode; runtime Zod schemas; React UI; a small Node HTTP adapter; PostgreSQL transactions, constraints, jobs, and backups; Playwright E2E; structured JSON logs. Select exact framework and supported versions when the repository exists. Email/push remain provider-neutral ports with acceptance lookup and idempotent send. No package installation is part of this task. There is no existing stack to diverge from. Do not use a cache or external queue as the source of consent or deduplication.
7. Client-side logic
7.1 Journeys to interfaces
| Surface / journey | Contract and client behavior |
|---|---|
| Inline save and empty results | Capabilities GET then POST; unchecked channel choices; readable criteria summary; preserve draft and request key on retry |
| Guest sign-in | Store draft in sessionStorage with opaque return key and 30-minute TTL; clear on success, explicit cancel, logout, or expiry |
| Management | GET list; PATCH with revision; DELETE only after dialog confirmation; refresh from server response |
| Resolve blocked channel | Existing verification/permission ports; re-fetch capabilities; never auto-select a previously unavailable channel |
| Email preferences | GET token page then POST selected scope; confirm email off; push unaffected |
| Alert destination | Opaque link resolver; single listing or current results; preserve attribution through sign-in and result clicks |
7.2 Error states
| Error / response | UI state |
|---|---|
| INVALID_REQUEST / INVALID_CRITERIA | Inline field message; retain values; invalid catalog filter shows Update this search |
| CHANNEL_UNAVAILABLE | Verification or permission helper; retain desired choice locally; ask explicit save with available channels or alerts off |
| DUPLICATE_SEARCH | Open owned existing search for editing; do not overwrite it |
| SEARCH_LIMIT | PRD limit copy and management link |
| STALE_REVISION | Show latest state; keep local draft for review; no silent overwrite |
| SIGN_IN_REQUIRED | Sign-in with local draft/destination restoration |
| FEATURE_DISABLED | Hide new setup; keep management; explain new saves unavailable |
| CSRF_FAILED / INVALID_SIGNATURE | Reload/retry securely; no state change; signature errors are internal only |
| NOT_FOUND | Neutral not-found state; return to management; never reveal ownership |
| LINK_EXPIRED | Link expired; offer sign-in to preferences or current search; no token renewal without authentication |
| RATE_LIMITED | Preserve input; retry after indicated delay |
| TEMPORARY_FAILURE / network timeout | PRD failure copy; same idempotency key on retry; fetch current state if outcome uncertain |
| Listing unavailable | PRD unavailable copy and current matching results |
| Empty list | Find listings entry; no alert-state badge |
7.3 Analytics events
All events carry eventId, time, experiment group, and pseudonymous user key; add channel/search/listing/delivery IDs only when applicable. Server owns authoritative state events. Exclude staff/test accounts in cohort queries.
| Event | Origin / metric |
|---|---|
| save_search_exposed | Client, deduplicated per user/session; activation denominator |
| alert_enabled | Server, first effective channel activation; activation numerator |
| eligible_match | Server, one user/listing/channel record plus winning search; primary denominator and timeliness start |
| provider_accepted | Server from adapter/reconciliation; acceptance time, channel, delivery members; timeliness and duplicates |
| attributed_listing_view | Server after actual listing page view; delivery membership and match age ≤7 days; useful discovery |
| alert_channels_changed | Server with effective before/after count; all-channel disablement window |
| alert_match_expired / delivery_failed | Server; delayed/failed cohorts and reasons |
| attribution_missing | Server when a known alert context cannot join; separate diagnostic |
Primary denominator uses users with active alerts and eligible matches during L..L+23 days, counted once. Numerator uses a matched listing view through an alert within seven days of that match. Result-page landing alone does not count; join clicked listing to delivery items. Retention is another attributed view on a later UTC day within 30 days. Report a sensitivity check using buyer-local days if needed. No email-open tracking. Compare experiment return listing views over 30 days; do not label the descriptive primary metric causal. Duplicate audit uses repeated accepted listing/channel members, including provider reconciliation evidence.
7.4 Client validation and security
Client checks name length, selected filters, channel readiness, and form validity. Server repeats every check. Escape names, titles, and summaries; render no user-provided HTML. Keep auth in secure HttpOnly cookies. Do not persist drafts beyond the tab. Do not expose tokens to third-party analytics. Set no-referrer and no-store on token pages; remove tokens from visible URL after secure form initialization. Keep labels, keyboard controls, focus restoration, and live status announcements from the mockups.
8. Logging and alerts
Log request/event/delivery IDs, operation, duration, outcome, generation, attempt, adapter, and reason. Never log queries, names, precise locations, addresses, device tokens, link tokens, or message bodies. Restrict audit access to support roles; record access.
| Signal | Initial threshold (Assumption) / action |
|---|---|
| Wrong recipient or cross-account access | Any confirmed event: stop sends, page security/on-call, preserve minimal audit |
| Duplicate ratio | ≥0.1% over rolling day with ≥1,000 deliveries, or repeated confirmed duplicates: stop affected adapter; investigate keys |
| Timeliness | <95% over rolling hour with ≥100 matches: warn; segment cap/provider/matcher delay, never hide capped matches |
| Matcher/worker lag | >60 s for 5 min outside window wait: page; inspect leases and DB |
| Unknown provider outcomes | Oldest >5 min: page; reconcile; keep reservations held |
| Dead letters | Any unknown schema or >10 processing failures: alert; isolate event and replay after correction |
| Infrastructure | DB unavailable >1 min, storage >80%, pool saturation >80% for 5 min, backup failure: page |
| Management | 5xx >1% for 5 min or p95 >500 ms for 10 min: investigate |
| Cap backlog | Oldest >24h: warn; seven-day expiry count reported daily |
| Privacy deletion | Purge exceeds stated deadline: page privacy/on-call |
Expose replay and reconciliation tools by ID with dry-run default. Feature flag defaults off. Delivery kill switch blocks new handoffs and retries; unknown acceptance reconciliation continues. No silent production reset of deduplication keys.
9. Single points of failure
| Failure point | Severity | Recovery |
|---|---|---|
| PostgreSQL | Critical: settings, matching, delivery halted | Managed failover; recover backups; reconcile provider keys before allowing sends after restore |
| Publication feed | High: missing/late matches | Producer transactional outbox, durable cursor, replay by immutable event ID; compare first-publication ledger to source |
| Search parser/evaluator | Critical: irrelevant or missed matches | Versioned parity fixtures; halt incompatible versions; pause invalid searches; never broaden |
| Email/push provider | High: one channel delayed | Bounded retry and status lookup; no unconsented fallback; expire at 24h |
| Identity/device service | High: cannot prove eligibility | Fail closed on new handoff; retain pending matches; settings can disable channels without dependency success |
| Worker process | Medium: delayed windows | Leases expire; another worker resumes; stable keys prevent duplicate handoff |
| Token secrets/encryption keys | Critical: link leakage or unreadable data | Managed secret rotation with overlap for active tokens; revoke compromised tokens; restore key access before sends |
| Analytics exporter | Medium: readout incomplete | Durable outbox replay; visible watermark; do not expand on incomplete data |
10. Security considerations
| Threat | Mitigation |
|---|---|
| Cross-account read/write | Resolve user from authenticated session; scope every lookup; return 404 for foreign IDs; exercise adversarial E2E |
| Forged publication/feedback | Service authentication, signed webhook with timestamp tolerance, replay keys, strict schemas |
| CSRF or link prefetch opt-out | Auth mutations use CSRF; token GET is read-only; explicit POST with nonce performs opt-out |
| Token theft/brute force | 256-bit random capability, hash at rest, expiry, limited scope, rate limiting; token cannot enable alerts or read account data |
| Open redirect and destination abuse | Opaque internal destination, allowlisted same-origin routes; authenticate before rendering saved criteria |
| Sensitive-interest disclosure | Generic lock-screen copy; saved name only in authenticated UI; email uses public listing details; no precise private location |
| Injection or resource exhaustion | Runtime schema limits, parameterized SQL, escaped templates, bounded filter complexity and worker pages |
| Consent race | Shared transaction locks, generation fences, explicit handoff boundary, immutable handed payload |
| Abuse of saves or providers | Twenty-search limit under lock; API quotas; cap reservations; no silent fallback channel |
| Account deletion and replay | Synchronous account stop, token revoke, durable purge ledger; replay checks deletion before any send |
Before beta, request specialist review of capability links, deletion/backup policy, messaging consent requirements, and provider signature validation. These checks are planned dependencies; no legal compliance claim is made.
Phase 2 gate: Approved by explicit user instruction for this run. No reviewer participated. Proceed to program design with labeled assumptions G1–G6 and A1–A12.
11. Program design
This is a proposed greenfield layout (Assumption A10), not a description of existing files. All feature files below are new. The implementing team must bind external ports before staging readiness. Pseudocode defines shapes, not implementation bodies.
11.1 File-tree diff
+ package.json # Scripts and approved dependencies; installs need separate authorization
+ tsconfig.json # Strict TypeScript; no implicit any
+ playwright.config.ts # Browser/API E2E, JSON reports and traces
+ src/contracts/alerts.ts # Runtime schemas, API DTOs, events, error codes
+ src/domain/criteria.ts # Canonical criteria and shared search-semantic port
+ src/domain/alerts.ts # State transitions, activation, overlap and cap policies
+ src/ports.ts # Identity, listing, publication, provider, flags and clock ports
+ src/db/001-searches.sql # Account/search/channel/idempotency schemas and indexes
+ src/db/002-matching.sql # Inbox, candidate index, claims and jobs
+ src/db/003-delivery.sql # Deliveries, members, budgets, tokens and feedback
+ src/db/004-observability.sql # Analytics events and purge indexes
+ src/db/store.ts # Transaction boundary and typed persistence operations
+ src/services/searches.ts # Save, edit, pause, resume, delete, eligibility transitions
+ src/services/matching.ts # Publication ingestion, parity evaluation, winning claims
+ src/services/delivery.ts # Window grouping, cap summary, immutable sends and retries
+ src/services/preferences.ts # Scoped/global email off and token validation
+ src/services/destinations.ts # Safe link resolution, fallback and attribution
+ src/services/lifecycle.ts # Filter invalidation, account stop/purge and retention
+ src/services/telemetry.ts # Transactional event emission and metric export
+ src/http/routes.ts # Auth, CSRF, runtime validation and route mapping
+ src/adapters/marketplace.ts # Real identity/search/listing/feed bindings; no silent mock fallback
+ src/adapters/providers.ts # Provider capability checks, send/lookup and signed feedback
+ src/adapters/flags.ts # Stable pilot/holdout and independent delivery switch
+ src/server.ts # API composition root and dependency wiring
+ src/worker.ts # Leased job runner, scheduled windows, retry and purge jobs
+ src/client/api.ts # Typed fetch, idempotency and revision handling
+ src/client/SaveSearch.tsx # Inline setup, guest draft and blocked-channel state
+ src/client/SavedSearches.tsx # Management, editor, pause/resume, confirmed delete
+ src/client/EmailPreferences.tsx # Token-scoped read-only page plus explicit opt-out form
+ src/client/AlertDestination.tsx # Sign-in return, listing/results and unavailable fallback
+ src/client/routes.tsx # Proposed route mounts into marketplace client
+ src/templates/alerts.ts # Generic push and escaped email templates with exact counts
+ e2e/fixtures.ts # Synthetic users/listings, controllable clock and adapter fixtures
+ e2e/alerts.spec.ts # Save/manage/guest/validation journeys
+ e2e/matching.spec.ts # Publication/parity/edit/overlap/invalid-filter journeys
+ e2e/delivery.spec.ts # Windows, caps, retry, concurrency and cancellation journeys
+ e2e/preferences.spec.ts # Scoped/global opt-out, tokens and destination attribution
+ e2e/lifecycle.spec.ts # Revocation, account purge, retention, flags and metrics
+ e2e/load.ts # Repeatable synthetic load and backlog recovery scenario
+ scripts/e2e.mjs # Start isolated app/worker/DB fixtures; write run manifest
+ scripts/replay.ts # Authorized ID-scoped dry-run/replay/reconciliation utility
+ docs/alerts-runbook.md # Flags, adapter capability results, alerts and recovery steps
No existing file is modified or deleted by this plan. Generated output goes under artifacts/e2e/<run-id>/ and includes report.json, manifest.json, provider-ledger.json, and failure traces. Reports must contain no live credentials or personal data. The current ERD and mockups are input documents, not runtime code.
11.2 Types and signatures
Pseudocode uses strict TypeScript-like types. Boundary inputs are unknown until runtime validation. IDs are branded strings, not interchangeable raw strings. Never use any. Transaction handles cannot escape their callback. Use discriminated outcomes rather than parsing error text.
type Channel = "email" | "push"
type Instant = branded UTC timestamp
type UserId, SearchId, ListingId, DeliveryId, EventId = distinct branded UUIDs
type Revision = branded positive integer
type Criteria = RuntimeValidated<CriteriaSchemaV1>
type ChannelConfig = {enabled: boolean, eligible: boolean,
activeAfter: Instant, generation: bigint}
type SearchState = {id: SearchId, userId: UserId, revision: Revision,
criteria: Criteria, createdAt: Instant,
criteriaActiveAfter: Instant, paused: boolean,
channels: Record<Channel, ChannelConfig>}
type DomainError = {code: ErrorCode, retryable: boolean, field?: string}
type Result<T> = {ok: true, value: T} | {ok: false, error: DomainError}
type Publication = {eventId: EventId, listingId: ListingId,
firstPublishedAt: Instant, snapshot: SearchDocument}
type ClaimKey = {userId: UserId, listingId: ListingId, channel: Channel}
type DeliveryState = "prepared" | "handed" | "unknown" | "accepted"
| "failed" | "cancelled" | "expired"
type SendOutcome = {kind: "accepted", providerId: string, acceptedAt: Instant}
| {kind: "rejected", permanent: boolean, reason: string}
| {kind: "unknown"}
type LookupOutcome = SendOutcome | {kind: "not_found", definitive: boolean}
type Destination = {kind: "listing", listingId: ListingId, fallback: Criteria}
| {kind: "results", criteria: Criteria}
interface Clock { now(): Instant }
interface IdentityPort {
getEligibility(user: UserId): Promise<Eligibility>
mostRecentPermittedDevice(user: UserId): Promise<DeviceDestination | null>
}
interface SearchSemantics {
parse(input: unknown): Result<Criteria>
canonicalize(criteria: Criteria): CanonicalCriteria
candidateBuckets(criteria: Criteria): NonEmptyReadonlyArray<Bucket>
matches(criteria: Criteria, document: SearchDocument): boolean
}
interface ListingPort {
getPublic(ids: readonly ListingId[]): Promise<ReadonlyMap<ListingId, PublicListing>>
}
interface ProviderPort {
capabilities(): {idempotentSend: boolean, acceptanceLookup: boolean}
send(key: DeliveryId, payload: FrozenPayload): Promise<SendOutcome>
lookup(key: DeliveryId): Promise<LookupOutcome>
verifyFeedback(raw: Uint8Array, headers: HeaderMap): Result<ProviderFeedback>
}
interface Flags {
cohort(user: UserId): "pilot" | "holdout" | "outside"
canCreate(user: UserId): boolean
canHandoff(channel: Channel): boolean
}
interface Store {
transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T>
leaseJobs(now: Instant, limit: number): Promise<readonly Job[]>
}
saveSearch(actor: UserId, input: SaveInput, key: IdempotencyKey): Promise<Result<Search>>
changeSearch(actor: UserId, id: SearchId, patch: PatchInput,
expected: Revision, key: IdempotencyKey): Promise<Result<Search>>
deleteSearch(actor: UserId, id: SearchId, expected: Revision,
key: IdempotencyKey): Promise<Result<void>>
ingestPublication(event: Publication): Promise<void>
matchPublication(eventId: EventId, cursor: CandidateCursor | null): Promise<PageOutcome>
chooseOwner(matches: readonly SearchState[], channel: Channel): SearchId | null
prepareWindow(search: SearchId, channel: Channel, window: Instant): Promise<DeliveryId | null>
releaseCapSummary(user: UserId, channel: Channel, now: Instant): Promise<DeliveryId | null>
tryHandoff(id: DeliveryId): Promise<SendOutcome | Deferred | Cancelled>
reconcileDelivery(id: DeliveryId): Promise<void>
applyFeedback(event: ProviderFeedback): Promise<void>
optOut(token: OpaqueToken, scope: "search" | "all"): Promise<Result<OptOutReceipt>>
resolveDestination(token: OpaqueToken, actor: UserId | null): Promise<Result<DestinationOrLogin>>
recordView(actor: UserId, listing: ListingId, context: AttributionContext): Promise<void>
stopAccount(user: UserId): Promise<void>
purgeAccount(user: UserId): Promise<PurgeReceipt>
invalidateFilters(event: FilterInvalidation): Promise<void>
exportEvents(cursor: EventCursor): Promise<ExportReceipt>
FrozenPayload includes exact member IDs/count, escaped public details, chosen device or verified address, link IDs, and schema version. It is never mutated after handoff. Injectable clock advances only in test/staging; production binds server UTC. Provider key remains stable across retries and recovery.
11.3 Call stacks
+ HTTP POST /saved-searches
+ routes.validateAndAuthenticate
+ searches.saveSearch
+ identity.getEligibility
+ criteria.parse / canonicalize
+ store.transaction
+ replayOrRejectIdempotencyKey
+ lockAccount / checkLimit / checkDuplicate
+ insertSearchAndChannels / candidateBuckets
+ telemetry.recordActivation / persistCommandResult
+
+ HTTP PATCH or DELETE /saved-searches/:id
+ routes.validateAuthCsrfRevision
+ searches.changeSearch or deleteSearch
+ store.transaction
+ lockAccountAndChannels / checkOwnerAndRevision
+ updateStateAndGeneration / cancelUnhandedClaims
+ persistCommandResult / telemetry.recordChange
+
+ publication consumer
+ matching.ingestPublication
+ store.insertInboxOnce
+ worker.leaseJobs
+ matching.matchPublication
+ candidateBuckets / searchSemantics.matches
+ store.transaction
+ recheckCurrentState / chooseOwner / upsertClaimOnce
+ telemetry.recordEligible / checkpointCandidatePage
+
+ worker.windowTick or budgetCapacityTick
+ delivery.prepareWindow or releaseCapSummary
+ listingPort.getPublic / templates.render
+ delivery.tryHandoff
+ store.transaction
+ recheckGenerationsAndConsent / claimBudget / freezeMembers
+ markHandedAndPersistKey
+ provider.send
+ store.recordOutcomeAndOutbox
+
+ worker.retryTick or HTTP signed provider feedback
+ delivery.reconcileDelivery or applyFeedback
+ provider.lookup or verifyFeedback
+ store.transaction
+ settleBudgetAndClaims / recordAcceptanceOnce
+ disablePermanentlyRejectedChannel if required
+
+ HTTP preference POST
+ preferences.optOut
+ validateCapabilityAndFormNonce
+ store.transaction
+ lockAccountAndChannels / disableEmailScope
+ incrementGenerations / cancelUnhandedWork / recordReceipt
+
+ HTTP /a/:token and listing page view
+ destinations.resolveDestination
+ validateToken / authenticateOwner / listingPort.getPublic
+ returnListingOrResultsOrFallback
+ destinations.recordView
+ validateDeliveryMembershipAndWindow / telemetry.enqueueViewOnce
+
+ account deletion, eligibility or filter event
+ lifecycle.stopAccount or searches.applyEligibility or invalidateFilters
+ store.transaction
+ stopEffectiveChannels / cancelUnhandedWork / revokeTokens
+ worker.purgeTick
+ lifecycle.purgeAccount / expireRetention
+
+ client route mount
+ api.getCapabilities or listSearches
+ SaveSearch / SavedSearches / EmailPreferences / AlertDestination
+ api.mutateWithStableKeyAndRevision
+ renderConfirmedServerStateOrMappedError
11.4 Test seams
Write failure scenarios before implementation. No post-hoc unit tests. Primary proof is browser/API → real isolated PostgreSQL → real worker → recording provider adapter → destination. Inject identity/search/listing/provider ports and Clock at composition roots. Do not mock domain services, database transactions, cap checks, or HTTP handlers.
| E2E file / seam | Failure-first cases and proof |
|---|---|
| alerts.spec.ts / identity fixture | Guest restore/cancel/expiry; zero-result save; channels unchecked; unverified/blocked channel; same key replay; changed body conflict; concurrent duplicate and 20-search limit; stale revision; foreign user; delete cancel |
| matching.spec.ts / publication and shared search fixture | Exact keyword/filter/price/location boundaries; old inventory; edit and reactivation; replayed event; overlapping searches with deterministic oldest owner; candidate index equals full scan; unknown/removed filter pauses; criteria edit/resume/channel enable excludes old publications |
| delivery.spec.ts / provider recorder and controlled clock | Quarter-hour boundary; no empty send; unavailable items/count; two channels; one device; four accepted messages across all searches; fifth waits until exact rolling expiry; two concurrent workers; crash before/after acceptance; timeout lookup; definite temporary failure; 24h expiry; cap summary and seven-day expiry |
| delivery.spec.ts / transaction barriers | Pause/delete/opt-out before handoff cancels; after boundary may arrive; no duplicate member on replay; unavailable-before-handoff drop; provider accepted but DB write failed recovers with same key |
| preferences.spec.ts / capability and browser | GET causes no change; scoped/global off keeps push; re-opt-in affects one search; forged/expired token; cross-user destination; sign-in restores link; removed listing fallback; count/results click attribution |
| lifecycle.spec.ts / event and flag fixtures | Revocation disables only affected channel; surviving permitted device used; account stops before purge; expired tokens; replay after purge cannot send; delivery kill switch and management availability; stable holdout; metric cohort calculations including capped matches |
| load.ts / generated volume | §4 peak plus 2× load; broad searches; DB contention; worker outage and drain; reports throughput, lag, cap counts, p95/p99 and parity mismatches |
Every run writes a manifest with seed, UTC/fake-clock times, dependency versions, command, expected/actual checks, database fixture identity, and exit status. Provider ledger records synthetic delivery IDs, members, key, attempts, and acceptance times. JSON report contains test results; failed browser cases add traces/screenshots. An empty test selection fails the run. Repeat the same seed and clock schedule to reproduce.
The fake provider proves our behavior, not vendor behavior. Before beta, run the same contract against sandbox providers with synthetic recipients. Verify idempotency lifetime ≥24h, definitive acceptance lookup, signed feedback, device invalidation, and rate limits. If either capability is absent, do not enable that channel until a revised design is approved. No integrated E2E was run during ERD writing.
11.5 Pattern choices
These questions record choices for the implementing reviewer. Recommendations are selected as
Assumptions under the user's gate instruction.
| Question | Selected recommendation and reason |
|---|---|
| Shared evaluator or separate alert matcher? | I decided to inject the shared evaluator because duplicated search semantics can diverge. |
| Route logic or service modules? | I decided to keep routes thin and put transactions in services because workers and HTTP controls share the same invariants. |
| ORM or explicit SQL? | I decided to use parameterized SQL behind typed store methods because lock order, partial indexes, and leases must be visible. |
| Exceptions or typed outcomes? | I decided to use discriminated domain results because UI error mapping needs stable codes; unexpected failures become logged 503 responses. |
| Global UI store or local state? | I decided to use local form state and authoritative API responses because the feature has small, separate surfaces. |
| Implicit toggles or explicit state transitions? | I decided to expose desired paused/channel states because retries of toggle commands can invert state twice. |
| Provider fallback or fail closed? | I decided to fail closed because user consent is channel-specific and ambiguous sends must not duplicate. |
11.6 Business rules
Only ordering-sensitive rules use pseudocode. These five procedures constrain implementation.
Rule 1 — mutation and activation boundaries
Validate shape and ownership; parse canonical criteria with shared search semantics.
For enabling a channel, obtain current eligibility; fail without mutation if unavailable.
Lock account, then channel budgets, then owned search rows.
Return stored idempotent result before considering revision or duplicate errors.
Check feature eligibility for creation; management is allowed while rollout is off.
Check revision, duplicate criteria, and live-row limit atomically.
On criteria edit: bump criteria and channel generations; set active_after = commit-time boundary.
On resume: set active_after for every enabled channel; discard paused-period backlog.
On newly enabled channel: bump only that channel generation; set its active_after.
On rename: keep generations and pending work.
On pause/delete/disable: cancel all affected unhanded work and release its reservations.
On global email off: disable email on every current search; never change push.
Commit state, receipt, idempotent result and telemetry together.
Activation timestamps are taken from the database transaction immediately before commit. Publications must be strictly later. A publication arriving late still uses firstPublishedAt, not ingestion time. Clock skew is monitored on the publication producer; imported timestamps cannot bypass activation.
Rule 2 — publication matching and ownership
Deduplicate immutable event ID and first-publication listing ID.
Enumerate a complete superset of candidates, including wildcard searches.
Evaluate exact shared semantics; require firstPublishedAt > effective active_after.
For each user/channel, evaluate all matching active searches before choosing the oldest.
Lock account/channel; reread active generations to exclude concurrent edits or pause.
Insert one claim per user/listing/channel; event replay is a no-op.
Keep an accepted or handed claim terminal; never send that listing/channel again.
If a pending owner becomes invalid, cancel it; do not bypass cancellation by reassignment.
Record eligibility once; retain original eligibility time even if later capped or cancelled.
Mark publication complete only after all bounded candidate pages are durable.
For paged scans, retain candidates per user until all their searches are evaluated; an account has at most 20. Ownership never depends on page order. Cancellation does not fall through to a younger overlapping search for the same publication (Assumption, to preserve stop intent). Later publications can match the remaining searches.
Rule 3 — grouping, rolling cap, and handoff
Group pending claims by search, channel, generation and UTC 15-minute window.
At window close, remove expired claims and listings no longer public.
If no members remain, cancel preparation; send nothing.
Read current eligibility/device and the delivery stop switch; fail closed if unknown.
Lock account/channel/search; reread consent, generations, and cancellation markers.
Count accepted slots with accepted_at > now - 24h, plus unresolved reservations.
If count >= 4, hold pending claims; schedule at earliest known release or reconciliation.
When capacity returns, choose oldest pending search and combine its deferred windows.
Create one exact-count summary; leave other searches pending, ordered oldest first.
Reserve one message slot, freeze members/destination/payload, persist stable provider key.
Commit handed state as the cancellation boundary; invoke provider immediately.
Use one most recently active permitted device, tie-breaking by device ID.
On acceptance, set slot accepted_at to provider acceptance time and complete claims.
A normal window emits at most one logical message per search/channel/generation. A retry is the same message. A cap summary consolidates deferred windows and consumes one slot. Render at most ten listing cards in email but retain exact total count and a View matches CTA. Current search results may include older inventory; only recorded delivery members qualify for alert attribution.
Handoff limitation — Assumption: the durable handed transition defines dispatch authorization immediately before the external call. It closes the unavoidable database/provider transaction gap. A control confirmed after this boundary cannot recall that attempt, even if the worker crashes before transmitting it. A recovery worker must recheck current consent before making a previously unattempted call; if consent is off, reconcile only and cancel if absence is definitive. Never replay a suppressed message blindly. This boundary and the mockup's “already-sent” wording require readiness review; document the copy as “Messages already being sent may still arrive.” This proposed copy clarification is a product gap, not a PRD edit.
Availability is checked just before handoff. A listing removed between that read and provider acceptance can still appear; its destination must show the unavailable fallback. No distributed lock with listing publication is assumed.
Rule 4 — retries and feedback
Persist first_attempt_at and deadline = first_attempt_at + 24h on first dispatch.
For ambiguous send: keep cap slot and immutable members; mark unknown.
Lookup provider key before retry; accepted lookup settles without another message.
If absence is definitive and current consent permits, retry same key and payload.
If consent changed, reconcile only; do not issue a new attempt.
Use delays 1m, 5m, 15m, then 60m with bounded jitter until deadline.
Respect provider Retry-After without extending the 24h deadline.
For definite temporary nonacceptance, keep the same reservation until retry or expiry.
For permanent rejection, release unsent slot; disable affected channel and show action needed.
If push token is invalid, remove that device; disable push only if none remain.
After deadline, expire known-unsent work; retain unknown slot until definitive reconciliation.
Deduplicate feedback; accepted status never regresses on late temporary errors.
A later bounce does not erase acceptance or refund the rolling accepted-message count.
Unknown outcomes can block a channel beyond 24h; page operators instead of risking duplicates. If lookup cannot resolve them, the provider fails A6. Do not fabricate acceptance or release unknown budget reservations. Preserve original metrics for a late acceptance.
Rule 5 — links, opt-out and deletion
GET validates token and renders a form or safe destination; never changes preferences.
Preference POST validates hash, scope, expiry, form nonce and account liveness.
Apply search/all email off under the same account/channel locks as dispatch.
Replaying opt-out succeeds; enabling always requires authenticated explicit choice.
Destination requires matching authenticated owner before exposing criteria or saved context.
Count a view only after loading a member listing, not from email open or redirect alone.
On account deletion, commit stop marker and revoke tokens before acknowledging event.
Cancel unhanded work, reconcile handed outcomes without sending, then purge by deadlines.
Retain a minimal deletion marker until publication replay and backup horizons expire.
Phase 3 gate: Approved by explicit user instruction for this run. No reviewer participated. Proceed to vertical slices using this proposed layout.
12. Vertical slices
Implement one to three slices per batch. Before each slice, add the named failing E2E scenario and retain its red report. Implement only that slice, then retain the green report. Target 100–200 handwritten changed lines per slice, excluding generated lockfiles and reports. These are estimates. If a slice exceeds 200 lines, split at a runnable scenario boundary and update this plan. Do not split into migrations-only or UI-only work.
Proposed harness command after slice 1: npm run e2e -- --grep '<case-id>' --run-id '<slice>-green'. Every run writes artifacts/e2e/<run-id>/{manifest.json,report.json,provider-ledger.json}. For a red run use <slice>-red. These commands are planned, not available in this document-only workspace. Dependency installation requires separate user authorization.
-
Tracer bullet: mock list through every planned layer.
- Files: package.json, tsconfig.json, playwright.config.ts, scripts/e2e.mjs, e2e/fixtures.ts, e2e/alerts.spec.ts, src/contracts/alerts.ts, src/ports.ts, src/db/store.ts, src/services/searches.ts, src/http/routes.ts, src/server.ts, src/client/api.ts, src/client/SavedSearches.tsx, src/client/routes.tsx.
- Change: serve one synthetic saved search through HTTP → service → injected mock repository; show it in the client. Wire real interfaces with minimal fields. Worker/provider paths enter later slices.
- Touch: start the isolated harness and open
/saved-searches; inspectGET /api/saved-searches. - Failing-first test
S01_tracer_list: no route/row before; matching API and visible synthetic row after. This is an explicit mock baseline, not production storage.
-
Persist an alerts-off search.
- Files: src/db/001-searches.sql, src/db/store.ts, src/domain/criteria.ts, src/contracts/alerts.ts, src/services/searches.ts, src/http/routes.ts, src/client/SaveSearch.tsx, src/client/routes.tsx, e2e/alerts.spec.ts.
- Change: replace mock repository for create/list with real PostgreSQL. Save zero-result criteria with no channels. Keep identity/search adapters synthetic.
- Touch:
/search→ Save search with no channel →/saved-searches; restart API and reload. - Test
S02_persist_off: row survives restart and shows alerts off; no notification work exists.
-
Make create safe under retries and limits.
- Files: src/domain/alerts.ts, src/services/searches.ts, src/db/store.ts, src/client/api.ts, src/client/SaveSearch.tsx, e2e/alerts.spec.ts.
- Change: replace optimistic duplicate/limit checks with locked constraints, durable command receipts, canonical criteria duplicate detection, and UI error mapping.
- Touch: save identical criteria twice; send concurrent creates at 19 existing searches.
- Test
S03_create_races: same-key replay creates once; altered-body replay conflicts; duplicate opens editor; count never exceeds 20.
-
Bind authenticated setup and channel eligibility.
- Files: src/adapters/marketplace.ts, src/ports.ts, src/server.ts, src/services/searches.ts, src/http/routes.ts, src/client/SaveSearch.tsx, e2e/alerts.spec.ts.
- Change: replace the fixed identity fixture at the production composition root with explicit account/device adapter bindings. Add guest draft restoration and email/push eligibility. Keep synthetic adapters in E2E only. Fail startup if live bindings are missing.
- Touch: guest save → sign-in → restored setup; deny push permission; select verified email.
- Test
S04_identity_eligibility: cancellation creates nothing, foreign user cannot read/change search, unavailable channels never show active. Real identity adapter conformance is a readiness prerequisite.
-
Manage settings and future activation.
- Files: src/services/searches.ts, src/domain/alerts.ts, src/http/routes.ts, src/client/SavedSearches.tsx, src/db/store.ts, e2e/alerts.spec.ts.
- Change: replace static management controls with revision-aware edit, rename, pause/resume, and confirmed delete. Establish generation boundaries even before matching exists.
- Touch:
/saved-searches; edit in two tabs; cancel then confirm delete. - Test
S05_manage: rename preserves generation; criteria edit/resume advance activation; stale tab gets 412; delete cancellation keeps row, confirmation removes it.
-
Ingest and prove one exact new match.
- Files: src/db/002-matching.sql, src/services/matching.ts, src/domain/criteria.ts, src/adapters/marketplace.ts, src/worker.ts, src/db/store.ts, e2e/matching.spec.ts.
- Change: replace the publication fixture path with a durable inbox/worker path and shared evaluator port; expose fixture publication only in isolated test harness.
- Touch: save active search; inject one first-publication event through the harness; inspect its claim in the JSON report.
- Test
S06_first_publication: one post-activation match creates one claim; old inventory, listing edit, reactivation, wrong filter, replay, and pre-activation delayed event create none.
-
Resolve overlap and live criteria changes.
- Files: src/services/matching.ts, src/services/searches.ts, src/domain/alerts.ts, src/db/store.ts, e2e/matching.spec.ts.
- Change: replace single-search matching with candidate superset lookup and full per-user/channel ownership. Connect generation changes to claim cancellation.
- Touch: save two overlapping searches, publish one listing, edit/pause the oldest before send preparation.
- Test
S07_overlap: oldest owns one claim per channel independent of page order; edited/paused pending claim cancels; resume produces no backlog; full scan and candidate lookup agree.
-
Send one email window end to end.
- Files: src/db/003-delivery.sql, src/services/delivery.ts, src/templates/alerts.ts, src/adapters/providers.ts, src/worker.ts, src/db/store.ts, e2e/delivery.spec.ts.
- Change: replace pending-claim inspection with a real window scheduler, delivery ledger and recording email adapter. Add durable stable key and budget reservation for every message, even this first path.
- Touch: create email search; publish two matches; advance controlled clock to quarter-hour close; inspect rendered email and provider ledger.
- Test
S08_email_window: one exact-count message; unavailable members excluded; empty batch sends nothing; one slot used. Live sending remains disabled.
-
Deliver push to one permitted device.
- Files: src/services/delivery.ts, src/adapters/providers.ts, src/templates/alerts.ts, src/ports.ts, e2e/delivery.spec.ts.
- Change: replace push stub with a provider adapter and deterministic device selection; retain generic lock-screen text.
- Touch: enable both channels with two permitted devices; publish a matching listing.
- Test
S09_push_device: one email and one push, most recent device only; no private location/name; missing optional email details omitted; no automatic channel fallback.
-
Enforce the rolling cap and deferred summary.
- Files: src/services/delivery.ts, src/domain/alerts.ts, src/db/store.ts, src/worker.ts, e2e/delivery.spec.ts.
- Change: replace initial single-slot flow with shared user/channel cap queries, oldest-search deferred summary, and seven-day backlog expiry.
- Touch: cause five windows across multiple searches; advance to exactly 24h after first acceptance, then seven days.
- Test
S10_cap_summary: at most four slots accepted/reserved; concurrent workers cannot overshoot; one summary releases at capacity; remaining searches stay queued; expired members are recorded.
-
Recover uncertain delivery without duplicates.
- Files: src/services/delivery.ts, src/adapters/providers.ts, src/http/routes.ts, src/worker.ts, src/db/store.ts, scripts/replay.ts, e2e/delivery.spec.ts.
- Change: replace success-only provider behavior with durable lookup/retry, signed deduplicated feedback, and 24-hour expiry. Add dry-run reconciliation command.
- Touch: harness simulates provider acceptance followed by process crash; restart worker and run ID-scoped reconciliation.
- Test
S11_retry_recovery: one accepted payload after crash/replay; unknown reserves capacity; consent change blocks a new call; permanent rejection disables only affected channel; retry deadline is enforced.
-
Cancel pending sends from email preferences.
- Files: src/services/preferences.ts, src/services/searches.ts, src/http/routes.ts, src/client/EmailPreferences.tsx, src/client/routes.tsx, src/db/store.ts, e2e/preferences.spec.ts, e2e/delivery.spec.ts.
- Change: replace mock preference form with scoped capability tokens and real opt-out. Connect all management stop actions to the same handoff locks.
- Touch: email footer → preference page → search/all email off; pause/delete while worker is held before handoff.
- Test
S12_optout_races: GET changes nothing; pre-handoff stop cancels, post-boundary may arrive; push remains; explicit re-opt-in enables one search only; expired/forged tokens fail.
-
Resolve real destinations and record useful views.
- Files: src/services/destinations.ts, src/services/telemetry.ts, src/db/004-observability.sql, src/http/routes.ts, src/client/AlertDestination.tsx, src/client/routes.tsx, src/templates/alerts.ts, e2e/preferences.spec.ts.
- Change: replace placeholder message links with scoped same-origin resolution, frozen criteria fallback, and member-aware actual-view events.
- Touch: open single and multiple-match messages; sign in; remove listing then revisit; click a matching results item.
- Test
S13_destination_view: correct listing/results, preserved sign-in target, unavailable fallback, cross-user denial, no count on redirect alone, eligible member view counted once.
-
Handle revocation, schema invalidation and account deletion.
- Files: src/services/lifecycle.ts, src/services/searches.ts, src/adapters/marketplace.ts, src/worker.ts, src/db/store.ts, src/client/SavedSearches.tsx, e2e/lifecycle.spec.ts.
- Change: replace fixed lifecycle fixtures with durable event handlers, immediate stop markers, and timed purge. Integrate invalid-filter pause and action-needed UI.
- Touch: revoke device/email, remove filter, then delete synthetic account while a batch is prepared.
- Test
S14_lifecycle: unaffected channels survive; invalid filter never broadens; deleted account cannot send; purge receipt meets deadlines; replay/restore deletion ledger still blocks sends.
-
Measure rollout and operate the stop controls.
- Files: src/adapters/flags.ts, src/services/telemetry.ts, src/services/delivery.ts, src/server.ts, src/worker.ts, src/client/SaveSearch.tsx, e2e/lifecycle.spec.ts, docs/alerts-runbook.md.
- Change: replace always-on test flags and event sink with stable pilot/holdout assignment, send stop, durable metric export, structured logs and operational alerts.
- Touch: switch entry and delivery flags independently; run a seeded metric cohort through export.
- Test
S15_flags_metrics: management stays available; no new sends while stopped; assignments remain stable; all PRD denominators/windows match fixture calculations, including capped and missing-attribution cases.
-
Prove adapter and capacity readiness.
- Files: e2e/load.ts, e2e/fixtures.ts, scripts/e2e.mjs, src/adapters/marketplace.ts, src/adapters/providers.ts, docs/alerts-runbook.md.
- Change: replace synthetic external bindings in staging with approved sandbox bindings. Record concrete capability results and measured capacity. No live deployment is part of this slice.
- Touch: run sandbox save → publish → email/push → destination; run peak load with a 15-minute worker outage and broad searches.
- Test
S16_readiness: exact evaluator parity; real publication durability; provider stable keys/lookup/signatures; §3 latency and recovery targets; reports show whether the unchanged 15-minute metric passes. If a prerequisite is unavailable, this slice fails and beta remains blocked.
Phase 4 gate: Approved by explicit user instruction for this run. No reviewer participated. This approves the plan, not installations, commits, ticket changes, deployment, or launch. Implementation begins only under a separate implementation request.
Handoff and change control
Keep §§1–10 as the durable architecture. Replace §§11–12 with the next change's plan after this feature merges. For implementation deviations, update affected architecture and list each departure under “Deviations from the ERD” in the eventual PR description. Product gaps return to the product owner; do not silently rewrite the PRD.
Between batches, a human runs the touch checks and reads the diff. This run's approved gates do not claim that those future checks occurred. Beta requires assigned owners, real adapter checks, passing E2E artifacts, timing feasibility evidence, and explicit launch authorization. Tentative PRD dates are not commitments.
Document verification
Validation script (validate-erd.py) checks this document's required sections, gate status, linked local files, diagram presence, fenced-block balance, and slice structure. Validation report (saved-search-alerts-erd-validation.json) records its result. This is document validation only. It does not prove code, provider delivery, runtime security, or production readiness.
search-inline.html
ZIP · project files
Example file from a real run - the skill writes it into your workspace.
Connects securely to your tools. The creator never sees your data.
About this skill
Design the code before your coding agent writes it. This skill covers phases 2 to 4 of the software factory Dex Horthy (HumanLayer) describes in Why Software Factories Fail. Each phase ends in a human review, so the reviewer settles the architecture, the shape of the code, and the build order before any code exists. ERD here means Engineering Requirements Document, not an entity-relationship diagram. The companion PRD skill covers phase 1.
What it does
- Reads the approved PRD and carries its scope, out-of-scope list, and constraints forward. A refactor with no product change starts here directly.
- Phase 2, system architecture. Requirements, API contracts per user journey, capacity, data schemas and query shapes, sequence diagrams, logging and alerts, failure points, and security.
- Phase 3, program design. A file-tree diff, types and signatures, call-stack trees, test seams, pattern choices, and business rules.
- Phase 4, vertical slices. A build order of small end-to-end slices. Slice 1 is a tracer bullet, and each slice ends in something a reviewer can touch and a proving test.
- Drafts one phase at a time with a review gate after each. Ask for all three and it drafts them in one pass, then walks you through them in order.
- Sends product gaps back to the PRD owner with a proposed answer, and proceeds on a labeled
Assumptioninstead of blocking.
Sizing
Large changes get all three phases. Medium changes get one plan doc with the architecture sections that apply plus the file tree and signatures, built in one pass. Small changes skip the ERD entirely.
Output
Markdown by default, so the coding agent reads it as context and Git diffs it cleanly. Each PR then lists its deviations from the ERD.
Pairs with the PRD skill, which writes the product requirements this skill starts from.
How to use
Skills on Agensi run inside your AI agent. Copy the prompt, paste it into your agent, and it connects to Agensi and installs this skill for you. If a payment is needed, your agent shows it to you first.
Reviews
No reviews yet
Be one of the first to try it. Every listed skill passes our trust checks below.
Security scanned
Passed our 8-point scan before listing
Fresh listing
Recently published to Agensi
Money-back guarantee
Doesn't do what its page promises? You get your money back.
Trust & safety
Security scanned
Verified clean 4 days ago
- Passed all security checks, Safe to install