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
| Plan | Page Cap | Cadences | Trends | Lifetime History |
|---|---|---|---|---|
| Free | 10 | daily | No | No |
| Base | 50 | daily, weekly | No | Yes |
| Pro | 125 | daily, weekly | Yes | Yes |
| Team | 300 | daily, weekly | Yes | Yes |
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 = TRUEpages - 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_cadencesarray - Free plan: daily only
- Paid plans: daily or weekly
History Gating:
can_access_lifetime_history = TRUE: 100 itemscan_access_lifetime_history = FALSE: 10 items
Subscription Lifecycle
States
| Status | Description |
|---|---|
active | User has full plan access |
canceled | Subscription ended; reverts to Free |
past_due | Payment 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_subscriptionsrow = Free plan - Trigger and application code both check for missing subscription
Notification System
Preferences
| Setting | Options | Default |
|---|---|---|
email_enabled | true/false | true |
email_address | email or null | null (uses profile email) |
digest_frequency | immediate, daily_digest | immediate |
quiet_hours_start | TIME or null | null |
quiet_hours_end | TIME or null | null |
Delivery States
| Status | Description |
|---|---|
pending | Initial state, awaiting processing |
queued | Scheduled for delivery |
sending | Delivery in progress |
sent | Successfully delivered |
failed | Delivery failed (see error_message) |
Deduplication
Two mechanisms prevent duplicate notifications:
-
Change-level: Unique constraint on
(page_change_event_id, channel)prevents duplicate notifications for the same change event per channel. -
Idempotency key: Optional
dedupe_keycolumn 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_startandquiet_hours_endare set: - Notifications queued during quiet hours held until window ends
- Uses user's timezone (TBD)
Cadence Model
Supported Values
| Cadence | Check Frequency |
|---|---|
daily | Once per 24h (UTC boundary) |
weekly | Once 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_daygenerated 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
| Column | Purpose |
|---|---|
ai_provider | Service: openai, anthropic, fallback, unknown |
ai_model | Model 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
| Rule | Description |
|---|---|
| Server-derived | brand_id is derived from the canonical URL—clients never pass it |
| Grouping only | Does NOT affect meaningful change detection, summarization, or notifications |
| Automatic upsert | Domain is created on first page from a domain |
| RLS via EXISTS | Users can view domains only for domains they track pages on |
Domain Extraction
Uses tldts library for proper public suffix list handling:
| URL | Registrable Domain |
|---|---|
https://docs.stripe.com/api | stripe.com |
https://status.github.io | status.github.io (public suffix) |
https://www.example.co.uk | example.co.uk |
http://localhost:3000 | localhost |
API Endpoints
| Endpoint | Purpose |
|---|---|
GET /api/brands | List user's domains |
GET /api/brands/[id] | Get domain with tracked pages |
GET /api/pages | Includes domain in response |
GET /api/pages/[id] | Includes domain in response |
Tracking Suggestions
Structure
tracking_suggestions: Categories of trackable pagestracking_suggestion_examples: Concrete URL patterns per suggestion
Fields
| Field | Purpose |
|---|---|
category | Grouping (e.g., "Pricing", "Status Pages") |
title | Display name |
description | Explanation of what to track |
icon_name | UI icon identifier |
is_active | Whether to show in discovery |
Visibility
- Only
is_active = TRUEsuggestions shown to users - Examples only visible if parent suggestion is active
Implementation References
| Component | File |
|---|---|
| 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 Schemas | packages/shared/src/schemas.ts |
| TypeScript Types | packages/shared/src/types.ts |
| Utility Functions | packages/shared/src/utils.ts (includes getRegistrableDomain) |
| Entitlement Functions | packages/shared/src/entitlements.ts |
| Database Queries | packages/db/src/queries.ts (includes domain functions) |
| Database Client | packages/db/src/client.ts |
| Email Client | packages/email/src/client.ts |
| Email Templates | packages/email/src/templates/ |
| Domain Backfill | scripts/backfill-brand-sites.ts |
Email Infrastructure
- Provider: Resend (
@eko/emailpackage) - Feature flag:
EMAIL_ENABLEDenvironment 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
| V1 | vNext |
|---|---|
Per-user pages | Global pages table |
| User owns page directly | User links to global page via user_pages |
| Duplicate checks for same page | Single check per page globally |
History Gating (vNext)
In vNext, history gating is time-based rather than count-based:
| Plan | History Access |
|---|---|
| Free | From added_at (when user added page) |
| Paid | Full history (NULL = no limit) |
Implementation:
user_pages.history_start_atcolumnNULL= 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_pagestable (notpages) - 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(formerlypage_observations)page_change_events(formerlypage_change_events)page_change_summaries(formerlysummaries)
AFTER INSERT triggers automatically populate vNext tables.
vNext Implementation References
| Component | File |
|---|---|
| vNext Migration | supabase/migrations/0007_global-url-library-vnext.sql |
| URL Verification | packages/shared/src/url-verification.ts |
| History Gating | packages/shared/src/entitlements.ts |
| vNext Queries | packages/db/src/queries.ts |
| Contract Doc | docs/contracts/global-url-library.md |