More screenshots

    Works with the AI Agents you already use

    Claude CodeClaude CodeCursorCursorCodex CLICodex CLIMuseMuseDotsDots+22 more

    ERD: Engineering Requirements Document

    1

    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

    Secure checkout via Stripe

    Money-back guarantee

    Converts to your local currency at checkout

    Security scanned
    ERD: Engineering Requirements Document

    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

    FieldValue
    StatusApproved for planning — user directed that each phase gate be treated as approved; no reviewer participated
    Phase 2: System architectureApproved by user instruction for this run; assumptions below remain unverified
    Phase 3: Program designApproved by user instruction for this run; no reviewer participated
    Phase 4: Vertical slicesApproved by user instruction for this run; no reviewer participated
    SizeLarge
    ReviewerNone in this run, per user instruction; implementation reviewer remains unassigned
    Serves PRDSaved-search alerts PRD (saved-search-alerts-prd.md)
    Updated2026-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 sourceClassificationRequirement or resolution
    F1 / §6–7 save, setup, zero resultsFunctional requirementAuthenticated ownership; preserve keyword, filters, explicit location/radius; omit sort/page. Name 1–80 Unicode characters. Channels unchecked. Zero results allowed.
    F2 / §7 guest, cancellation, retryFunctional requirementKeep a tab-local setup draft through sign-in; cancel creates nothing. Idempotent create; retain inputs on failure.
    F3 / §7 eligibility, §11 devicesFunctional requirementRequire 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 matchingConstraintShared search semantics. Only first publication after activation; exclude edits, reactivation, private/unavailable listings, and backfill.
    F5 / §7 overlapFunctional requirementOne listing per user/channel. Assign to oldest matching active search, ordered by created_at then ID. Two selected channels allow two deliveries.
    F6 / §7 timingProduct gap G1How 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 timelinessProduct gap G2How 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 summaryProduct gap G3Does 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 editFunctional requirementCriteria 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 managementFunctional requirementList, rename, edit, pause, resume, channel changes, confirmed delete. Maximum 20 nondeleted searches; identical canonical criteria return the existing search for editing.
    F11 / §7 invalid filtersFunctional requirementSchema/catalog invalidation pauses affected searches with Update this search. Never remove filters silently. Explicit corrected criteria and resume required.
    F12 / §7 failures/racesEngineering problemStable delivery keys, rolling cap reservations, durable work, bounded retries, and an ordered cancellation boundary; see §5 and §11.6.
    F13 / §7 revoked channelsFunctional requirementDisable permanently rejected email or revoked push when no eligible devices remain. Keep other channels and criteria. Show action needed.
    F14 / §7, §11 opt-outFunctional requirementSearch-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 destinationsFunctional requirementOne 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 rolloutConstraintStable 10% pilot and equal-sized 10% holdout, feature off initially; independent send stop control. Management remains available.
    F17 / §5 analyticsFunctional requirementMeasure exposure, activation, eligible match, provider acceptance, attributed view, later-day retention, opt-outs, and duplicates. No query/address in analytics.
    F18 / §10 ownershipProduct gap G4Who 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 preferenceConsiderationAssume quarter-hour batching and one device, as proposed. Validate with repeat buyers before expansion.
    F20 / §4, §7, §12 dependenciesEngineering problemAccounts, publication feed, exact search evaluator, and providers are absent here. Specify ports and synthetic fixtures; prove adapters before beta.
    F21 / §10 retention/deletionProduct gap G5What retention is acceptable? Assumption: bounded retention in §5.1; privacy owner must confirm before live data.
    F22 / §9, §13–14ConsiderationDates are tentative; no seeding in production. Use synthetic staging inventory. Readiness requires integrated delivery evidence and support examples.
    F23 / §7 cap backlogProduct gap G6How 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}
    
    JourneyInterface / inputSuccessErrors beyond common errors
    Setup eligibilityGET /api/alert-capabilities200 {emailVerified, pushPermitted, featureEnabled}—
    SavePOST /api/saved-searches, SaveInput201 Search; replay returns original409 DUPLICATE_SEARCH with owned existingSearchId; 409 SEARCH_LIMIT; 422 CHANNEL_UNAVAILABLE
    ManageGET /api/saved-searches200 {items: Search[]}; max 20, oldest first—
    Edit/rename/channels/pause/resumePATCH /api/saved-searches/:id, {name?,criteria?,channels?,paused?}200 Search and new revision409 DUPLICATE_SEARCH; 412 STALE_REVISION; 422 INVALID_CRITERIA / CHANNEL_UNAVAILABLE
    Confirm deletionDELETE /api/saved-searches/:id204; owned tombstone repeats 204412 STALE_REVISION
    Guest returnexisting sign-in port, opaque local return keyRestore tab-local draft; revalidate before submitCancel returns to results; expired draft returns to setup
    Preference displayGET /alert-email-preferences?t=<token>200 scoped form; no preference mutation410 LINK_EXPIRED; 404 NOT_FOUND
    Opt-outPOST /api/alert-email-preferences, `{token,scope:"search""all"}`200 {email:"off",scope}; idempotent
    Alert clickGET /a/:opaqueToken303 same-origin sign-in or destination410 LINK_EXPIRED; 404 NOT_FOUND; unavailable listing renders fallback
    Provider feedbackPOST /internal/alert-feedback/:provider, signed provider envelope202 {received:true} after durable inbox400 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.

    IDDecision and reason
    A1I 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.
    A2I 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.
    A3I decided to share a versioned criteria parser and evaluator with search because matching must preserve search meaning. No independent approximate text matcher is allowed.
    A4I 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.
    A5I decided to keep API writes synchronous and delivery asynchronous because the buyer needs confirmed settings, not a completed send.
    A6I 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.
    A7I 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.
    A8I decided to use scoped opaque tokens and same-origin destinations because notification links must not grant account access or enable open redirects.
    A9I 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.
    A10I decided to plan a new src/ layout because there are no repository conventions to extend. Existing PRD mockups remain unchanged.
    A11I 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.
    A12I 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

    1. Prove exact criteria parity on keyword, price, category, location, and missing-field boundaries.
    2. Ensure publication events cannot be lost between marketplace commit and ingestion.
    3. Make overlap ownership, caps, and retries safe under concurrent workers.
    4. Bound scans and cap backlogs without removing matches silently.
    5. Define cancellation ordering when a provider call is slow or ambiguous.
    6. Preserve attribution without leaking criteria or storing addresses in analytics.
    7. Reconcile provider acceptance after worker crashes; quarantine incompatible adapters.

    3. Non-functional requirements

    All infrastructure targets below are Assumptions, except PRD targets explicitly named.

    ConcernTarget / semantics
    Management availability99.9% successful valid requests per calendar month; exclude client errors, include dependencies
    API latencyp95 reads <300 ms; writes <500 ms at projected peak, excluding sign-in
    DeliveryPRD: ≥95% of eligible matches accepted within 15 minutes, measured from firstPublishedAt, per selected channel
    Internal latencyIngest/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
    ConsistencyStrong for ownership, consent, dedup keys, revision checks, and reservations; eventual for matching, provider feedback, analytics
    CorrectnessNo unauthorized recipient; no edits/reactivations/backfill. PRD duplicate target <0.1%; never claim exactly-once receipt
    RecoveryManaged DB backups: assumed RPO ≤5 min, RTO ≤60 min; reconcile external acceptance before resend after restore
    AccessibilityKeyboard operation, focus return, readable narrow layouts, status announcements; verify with browser E2E
    ManageabilityReplay by event ID, dead-letter inspection, per-channel stop switch, adapter health, trace IDs; no raw query logging
    MaintainabilityShared 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 / calculationEstimate
    Eligible buyers / pilot100,000 / 10,000; separate 10,000 holdout
    Saved searches3 per pilot buyer = 30,000; hard maximum 200,000
    First publications20,000/day = 0.23/s average; 10× peak = 2.3/s
    Initial full candidate scan30,000 × 20,000 = 600M evaluations/day = 6,944/s average; 69,444/s peak
    Conservative category/region candidate reductionAssumed 90% reduction: 694/s average, 6,944/s peak. Broad searches remain in wildcard buckets
    Eligibility fan-out20 user/channel matches per publication = 400,000/day; 4.6/s average, 46/s peak
    Maximum provider messages10,000 users × 2 channels × 4/day = 80,000/day, 0.93/s average; stagger close bursts over 30 s
    Management requests100,000/day = 1.16/s average, 12/s peak; assumed read/write ratio 4:1
    Search storage30,000 × 2 KB = 60 MB raw; ~180 MB with indexes
    Seven-day pending backlog ceiling estimate400,000/day × 7 × 300 B = 840 MB raw; ~2.5 GB indexed
    90-day audit/analytics400,000/day × 90 × 400 B = 14.4 GB raw, ~43 GB indexed; acceptance/message rows additional
    Dedup tombstones400,000/day × 80 B ≈ 32 MB/day raw; ~35 GB/year with 3× index overhead
    CacheNo required shared cache; bounded 64 MB process cache for versioned schema only
    Message sizePush ≤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.

    TableFields and constraints
    account_alert_stateuser_id PK, deleted_at nullable, consent_epoch bigint, timestamps. Row lock serializes create limit and all-channel control changes.
    saved_searchid 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_inboxevent_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.
    deliveryid 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_slotdelivery_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_tokentoken_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_outboxid 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_eventevent_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 / operationQuery/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 schedulermatch_claim (search_id,channel,generation,window_start) WHERE pending; (user_id,channel,expires_at); lease due delivery (state,next_attempt_at)
    Deduplicationmatch_claim composite PK; lock per user/channel before assigning/reassigning claim and creating delivery
    Rolling capbudget_slot (user_id,channel,accepted_at) plus partial unresolved index; count under channel_budget lock
    Cancellation/editmatch_claim and delivery indexes (search_id,channel,state); account-wide controls use (user_id,channel,state)
    Links and replayaction_token token_hash PK; command_result composite PK; expiry indexes for purge
    Deletion and analyticsuser_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 / journeyContract and client behavior
    Inline save and empty resultsCapabilities GET then POST; unchecked channel choices; readable criteria summary; preserve draft and request key on retry
    Guest sign-inStore draft in sessionStorage with opaque return key and 30-minute TTL; clear on success, explicit cancel, logout, or expiry
    ManagementGET list; PATCH with revision; DELETE only after dialog confirmation; refresh from server response
    Resolve blocked channelExisting verification/permission ports; re-fetch capabilities; never auto-select a previously unavailable channel
    Email preferencesGET token page then POST selected scope; confirm email off; push unaffected
    Alert destinationOpaque link resolver; single listing or current results; preserve attribution through sign-in and result clicks

    7.2 Error states

    Error / responseUI state
    INVALID_REQUEST / INVALID_CRITERIAInline field message; retain values; invalid catalog filter shows Update this search
    CHANNEL_UNAVAILABLEVerification or permission helper; retain desired choice locally; ask explicit save with available channels or alerts off
    DUPLICATE_SEARCHOpen owned existing search for editing; do not overwrite it
    SEARCH_LIMITPRD limit copy and management link
    STALE_REVISIONShow latest state; keep local draft for review; no silent overwrite
    SIGN_IN_REQUIREDSign-in with local draft/destination restoration
    FEATURE_DISABLEDHide new setup; keep management; explain new saves unavailable
    CSRF_FAILED / INVALID_SIGNATUREReload/retry securely; no state change; signature errors are internal only
    NOT_FOUNDNeutral not-found state; return to management; never reveal ownership
    LINK_EXPIREDLink expired; offer sign-in to preferences or current search; no token renewal without authentication
    RATE_LIMITEDPreserve input; retry after indicated delay
    TEMPORARY_FAILURE / network timeoutPRD failure copy; same idempotency key on retry; fetch current state if outcome uncertain
    Listing unavailablePRD unavailable copy and current matching results
    Empty listFind 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.

    EventOrigin / metric
    save_search_exposedClient, deduplicated per user/session; activation denominator
    alert_enabledServer, first effective channel activation; activation numerator
    eligible_matchServer, one user/listing/channel record plus winning search; primary denominator and timeliness start
    provider_acceptedServer from adapter/reconciliation; acceptance time, channel, delivery members; timeliness and duplicates
    attributed_listing_viewServer after actual listing page view; delivery membership and match age ≤7 days; useful discovery
    alert_channels_changedServer with effective before/after count; all-channel disablement window
    alert_match_expired / delivery_failedServer; delayed/failed cohorts and reasons
    attribution_missingServer 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.

    SignalInitial threshold (Assumption) / action
    Wrong recipient or cross-account accessAny 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 outcomesOldest >5 min: page; reconcile; keep reservations held
    Dead lettersAny unknown schema or >10 processing failures: alert; isolate event and replay after correction
    InfrastructureDB unavailable >1 min, storage >80%, pool saturation >80% for 5 min, backup failure: page
    Management5xx >1% for 5 min or p95 >500 ms for 10 min: investigate
    Cap backlogOldest >24h: warn; seven-day expiry count reported daily
    Privacy deletionPurge 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 pointSeverityRecovery
    PostgreSQLCritical: settings, matching, delivery haltedManaged failover; recover backups; reconcile provider keys before allowing sends after restore
    Publication feedHigh: missing/late matchesProducer transactional outbox, durable cursor, replay by immutable event ID; compare first-publication ledger to source
    Search parser/evaluatorCritical: irrelevant or missed matchesVersioned parity fixtures; halt incompatible versions; pause invalid searches; never broaden
    Email/push providerHigh: one channel delayedBounded retry and status lookup; no unconsented fallback; expire at 24h
    Identity/device serviceHigh: cannot prove eligibilityFail closed on new handoff; retain pending matches; settings can disable channels without dependency success
    Worker processMedium: delayed windowsLeases expire; another worker resumes; stable keys prevent duplicate handoff
    Token secrets/encryption keysCritical: link leakage or unreadable dataManaged secret rotation with overlap for active tokens; revoke compromised tokens; restore key access before sends
    Analytics exporterMedium: readout incompleteDurable outbox replay; visible watermark; do not expand on incomplete data

    10. Security considerations

    ThreatMitigation
    Cross-account read/writeResolve user from authenticated session; scope every lookup; return 404 for foreign IDs; exercise adversarial E2E
    Forged publication/feedbackService authentication, signed webhook with timestamp tolerance, replay keys, strict schemas
    CSRF or link prefetch opt-outAuth mutations use CSRF; token GET is read-only; explicit POST with nonce performs opt-out
    Token theft/brute force256-bit random capability, hash at rest, expiry, limited scope, rate limiting; token cannot enable alerts or read account data
    Open redirect and destination abuseOpaque internal destination, allowlisted same-origin routes; authenticate before rendering saved criteria
    Sensitive-interest disclosureGeneric lock-screen copy; saved name only in authenticated UI; email uses public listing details; no precise private location
    Injection or resource exhaustionRuntime schema limits, parameterized SQL, escaped templates, bounded filter complexity and worker pages
    Consent raceShared transaction locks, generation fences, explicit handoff boundary, immutable handed payload
    Abuse of saves or providersTwenty-search limit under lock; API quotas; cap reservations; no silent fallback channel
    Account deletion and replaySynchronous 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 / seamFailure-first cases and proof
    alerts.spec.ts / identity fixtureGuest 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 fixtureExact 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 clockQuarter-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 barriersPause/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 browserGET 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 fixturesRevocation 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.

    QuestionSelected 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.

    1. 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; inspect GET /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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.
    7. 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.
    8. 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.
    9. 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.
    10. 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.
    11. 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.
    12. 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.
    13. 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.
    14. 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.
    15. 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.
    16. 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

    Generated

    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 Assumption instead 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

    Listed4 days ago

    What's inside

    Frequently Asked Questions