Business Rules Specification

Covers V1 (per-user tracking) and vNext (global URL library) rules.

Purpose

Single source of truth for V1 business logic. Consolidates rules scattered across migrations, schemas, and application code.


Plan System

Plan Definitions

PlanPage CapCadencesTrendsLifetime History
Free10dailyNoNo
Base50daily, weeklyNoYes
Pro125daily, weeklyYesYes
Team300daily, weeklyYesYes

Plans are data-driven via plan_definitions table. Values can be updated without migrations.

Entitlement Enforcement

Page Cap:

  • Enforced at database level via check_page_cap() trigger on INSERT
  • Counts only is_active = TRUE pages
  • Users without subscription default to Free plan limits
  • Error message: "Page limit reached. Your plan allows X tracked pages."

Cadence Restrictions:

  • Validated against plan_definitions.allowed_cadences array
  • Free plan: daily only
  • Paid plans: daily or weekly

History Gating:

  • can_access_lifetime_history = TRUE: 100 items
  • can_access_lifetime_history = FALSE: 10 items

Subscription Lifecycle

States

StatusDescription
activeUser has full plan access
canceledSubscription ended; reverts to Free
past_duePayment failed; grace period active

Transitions

(no subscription) ─────► active (via Stripe checkout)
        │
        │ create free
        ▼
      active ─────────► canceled (user cancels)
        │
        │ payment fails
        ▼
     past_due ─────────► active (payment succeeds)
        │
        │ grace period expires
        ▼
     canceled

Default Behavior

  • No user_subscriptions row = Free plan
  • Trigger and application code both check for missing subscription

Notification System

Preferences

SettingOptionsDefault
email_enabledtrue/falsetrue
email_addressemail or nullnull (uses profile email)
digest_frequencyimmediate, daily_digestimmediate
quiet_hours_startTIME or nullnull
quiet_hours_endTIME or nullnull

Delivery States

StatusDescription
pendingInitial state, awaiting processing
queuedScheduled for delivery
sendingDelivery in progress
sentSuccessfully delivered
failedDelivery failed (see error_message)

Deduplication

Two mechanisms prevent duplicate notifications:

  1. Change-level: Unique constraint on (page_change_event_id, channel) prevents duplicate notifications for the same change event per channel.

  2. Idempotency key: Optional dedupe_key column with unique index enables callers to ensure a notification is only created once for any arbitrary key (e.g., {change_id}:{user_id}:{channel}).

Quiet Hours (Not Yet Implemented)

  • When quiet_hours_start and quiet_hours_end are set:
  • Notifications queued during quiet hours held until window ends
  • Uses user's timezone (TBD)

Cadence Model

Supported Values

CadenceCheck Frequency
dailyOnce per 24h (UTC boundary)
weeklyOnce per 7d

Next Check Calculation

next_check_at = last_checked_at + cadence_interval + jitter
  • Jitter: 0-6 hours random offset (prevents thundering herd)
  • UTC boundary: page_observations.checked_day generated column

One-Check-Per-Day Constraint

  • UNIQUE (page_id, checked_day) prevents multiple checks
  • Enforced at enqueue, worker guard, and database

AI Provider Tracking

Fields

ColumnPurpose
ai_providerService: openai, anthropic, fallback, unknown
ai_modelModel ID: gpt-4o, claude-3-sonnet, etc.

Provider Detection (Backfill)

ai_provider = CASE
  WHEN model_used LIKE 'gpt-%' THEN 'openai'
  WHEN model_used LIKE 'claude-%' THEN 'anthropic'
  WHEN model_used LIKE 'o1-%' THEN 'openai'
  WHEN model_used = 'fallback' THEN 'fallback'
  ELSE 'unknown'
END

Usage

  • Cost attribution per provider
  • Quality monitoring per model
  • Provider failover tracking

Domains

Purpose

Domains provide domain-based grouping for navigation and organization. Every tracked page is associated with a domain derived from its registrable domain (eTLD+1).

Key Rules

RuleDescription
Server-derivedbrand_id is derived from the canonical URL—clients never pass it
Grouping onlyDoes NOT affect meaningful change detection, summarization, or notifications
Automatic upsertDomain is created on first page from a domain
RLS via EXISTSUsers can view domains only for domains they track pages on

Domain Extraction

Uses tldts library for proper public suffix list handling:

URLRegistrable Domain
https://docs.stripe.com/apistripe.com
https://status.github.iostatus.github.io (public suffix)
https://www.example.co.ukexample.co.uk
http://localhost:3000localhost

API Endpoints

EndpointPurpose
GET /api/brandsList user's domains
GET /api/brands/[id]Get domain with tracked pages
GET /api/pagesIncludes domain in response
GET /api/pages/[id]Includes domain in response

Tracking Suggestions

Structure

  • tracking_suggestions: Categories of trackable pages
  • tracking_suggestion_examples: Concrete URL patterns per suggestion

Fields

FieldPurpose
categoryGrouping (e.g., "Pricing", "Status Pages")
titleDisplay name
descriptionExplanation of what to track
icon_nameUI icon identifier
is_activeWhether to show in discovery

Visibility

  • Only is_active = TRUE suggestions shown to users
  • Examples only visible if parent suggestion is active

Implementation References

ComponentFile
SQL Migration (V1)supabase/migrations/0006_v1_contracts.sql
SQL Migration (Dedupe)supabase/migrations/0008_notification_dedupe_key.sql
SQL Migration (Domains)supabase/migrations/0009_domains_v1.sql
Zod Schemaspackages/shared/src/schemas.ts
TypeScript Typespackages/shared/src/types.ts
Utility Functionspackages/shared/src/utils.ts (includes getRegistrableDomain)
Entitlement Functionspackages/shared/src/entitlements.ts
Database Queriespackages/db/src/queries.ts (includes domain functions)
Database Clientpackages/db/src/client.ts
Email Clientpackages/email/src/client.ts
Email Templatespackages/email/src/templates/
Domain Backfillscripts/backfill-brand-sites.ts

Email Infrastructure

  • Provider: Resend (@eko/email package)
  • Feature flag: EMAIL_ENABLED environment variable
  • From address: Configured via RESEND_FROM_EMAIL
  • Email sending is opt-in; disabled by default in development

vNext: Global Page Library

The vNext architecture introduces a shared global page library. Key differences from V1:

Page Ownership Model

V1vNext
Per-user pagesGlobal pages table
User owns page directlyUser links to global page via user_pages
Duplicate checks for same pageSingle check per page globally

History Gating (vNext)

In vNext, history gating is time-based rather than count-based:

PlanHistory Access
FreeFrom added_at (when user added page)
PaidFull history (NULL = no limit)

Implementation:

  • user_pages.history_start_at column
  • NULL = full history (paid plans)
  • Timestamp = gated from that date (free plans)
  • Set automatically by database trigger on INSERT

Page Cap Enforcement (vNext)

  • Enforced on user_pages table (not pages)
  • Trigger: check_library_page_cap()
  • Counts active entries per user

Page Submission Workflow (vNext)

User submits page → url_submissions (pending)
        ↓
Policy evaluation → url_policy_decisions
        ↓
Decision routing:
  - ALLOW → Create/link global page + add to library
  - BLOCK → Record decision, reject
  - REVIEW → Queue for admin

Write-Through Compatibility

During migration, V1 workers continue using:

  • page_observations (formerly page_observations)
  • page_change_events (formerly page_change_events)
  • page_change_summaries (formerly summaries)

AFTER INSERT triggers automatically populate vNext tables.

vNext Implementation References

ComponentFile
vNext Migrationsupabase/migrations/0007_global-url-library-vnext.sql
URL Verificationpackages/shared/src/url-verification.ts
History Gatingpackages/shared/src/entitlements.ts
vNext Queriespackages/db/src/queries.ts
Contract Docdocs/contracts/global-url-library.md