The spec
The source of truth for the build: locked decisions, architecture, data model and the phased delivery plan
Status: source of truth for the build · Owner: Esmee Peters · Baseline 2026-07-09
(decision 2 revised: the core is consumed as a git submodule, untouched). Decisions taken
after this date are recorded in DECISIONS_LOG.md and referenced inline.
Items marked [REVIEW] are defaults Esmee adjusts during review; everything else is decided.
0. Purpose and product principle
Owly Post Cloud is the hosted, paid layer on top of the open source Owly Post core (github.com/esmeepeters/owlypost). It sells convenience, not functionality:
Owly Post runs for you: no Docker, no maintenance, always the latest version, and your first digest within two minutes of signing up.
Model: NewsBlur pattern. The bare core stays free and self-hostable, unchanged. The cloud is the natural upgrade for people who know layer 1 but don't want the upkeep.
Goal of v1: the first paying customer. Anything that doesn't serve that goal is out of scope.
1. Decisions (locked)
| # | Topic | Decision |
|---|---|---|
| 1 | Business model | Open core (AGPL, free self-host) + paid hosted shell. No fork: shell consumes the core. |
| 2 | Core consumption | The core stays exactly what it is: a flat, single-user open source app (no workspace, no packages/core, no publishing). The cloud vendors the public repo as a git submodule (vendor/owlypost, public https URL, pinned to a commit on main) and consumes its lib/ through one cloud-owned bridge package (core-bridge/, named @owlypost/core in the cloud workspace) — the only place allowed to import vendor internals. Upgrades arrive as one submodule bump PR (Renovate git-submodules manager → CI gates → deploy). The core is never changed for the cloud's sake; the cloud only consumes commits that have landed on core main. |
| 3 | Tenancy | Multi-tenant, single instance. Shared schema with tenant_id on every core table, enforced by RLS and an explicitly scoped storage adapter (defense in depth). |
| 4 | Hosting | Netlify (app + scheduled/background functions). Dispatcher pattern for per-tenant jobs. |
| 5 | Database + auth | Supabase, region Frankfurt (eu-central-1). Supabase Auth, email + password. |
| 6 | LLM costs | Included in the price ("do it for you"). Operator-owned Anthropic key. Hard caps per tenant (§5). |
| 7 | Trial | 14 days, payment method required upfront via Stripe Checkout. Trial is a status, not a plan. |
| 8 | Payment methods | Stripe Checkout with dynamic payment methods (D-30): Stripe selects per customer location/currency from what the dashboard enables — no hardcoded list. Baseline enabled: card (Apple/Google Pay ride along), iDEAL, SEPA debit, Bancontact; iDEAL/Bancontact first payment establishes a SEPA mandate for renewals. The offering is managed with dashboard toggles, not deploys. |
| 9 | Price | €3.95/month incl. VAT, EUR only, framed publicly as a launch price. Stripe Tax on, tax_behavior: inclusive. No promotion codes or early-adopter discounts — one product, one price (D-29 supersedes the earlier promo-code idea). |
| 10 | Plans | One paid plan Early Owl (chosen 2026-07-20, D-28; one plans.name row, changeable any time without deploy) + Free (admin-granted only, no Stripe objects). Plans and prices decoupled: multiple stripe_price_ids can point to one plan. |
| 11 | Domains | App: app.owlypost.com (root + www serve the marketing site). Mailbox mail@owlypost.com via Surver. Auth sender no-reply@owlypost.com, digest sender owl@owlypost.com — both Resend on the root domain, no sending subdomain (D-27). |
| 12 | Delivery | Digests go to the verified account email only in v1. |
| 13 | Privacy promise | Data resides in the EU (Frankfurt). No training on customer data. Export on request. Data deleted 30 days after expiry. |
| 14 | Core stability | The open source core must keep running unchanged as a single-user self-host. Regression-tested in Phase 0. |
| 15 | Licensing | Esmee is sole copyright holder, so AGPL does not bind her own closed shell. External contributions to the core require a CLA before merging [REVIEW: CONTRIBUTING.md wording]. |
Owner requirements on the core↔cloud relationship (Esmee, 2026-07-09). These replaced the original plan — extracting the core into a workspace package and publishing it — and decision 2 implements them (DECISIONS_LOG P4-6):
- The core is never changed for the cloud's sake. It is an independent open source project; core changes are Esmee's own maintainer decisions, made on the core's merits.
- When the core changes, those changes can roll out to the cloud: one submodule bump PR, with all gates proving it safe.
- The core is the basis of the cloud: no fork, no copies, no reimplemented core logic in the shell.
2. System architecture
Two repositories:
owlypost (public, AGPL) owlypost-cloud (private)
├── app/ the single-user ├── vendor/owlypost ◀─ git submodule: the public
│ self-host UI │ repo, UNTOUCHED, pinned to a commit on main
├── lib/ pipeline + storage ├── core-bridge/ ─ cloud-owned bridge package,
│ (runIngest, runDigest, │ named @owlypost/core in the cloud workspace;
│ Storage interface, llm, │ re-exports vendor/owlypost/lib + cloud-owned
│ email — all env-configured) │ additions (applyFeedback)
└── worker/, scripts/, … ├── Next.js app (App Router)
├── tenant-scoped adapters
├── auth, billing, quotas, onboarding
└── Netlify functions:
dispatch (scheduled, */15 min)
ingest-tenant (background)
digest-tenant (background)Runtime topology (cloud):
user ──▶ Netlify: Next.js (app.owlypost.com)
│ anon key + RLS │ service role (server only)
▼ ▼
Supabase Postgres + Auth (Frankfurt)
▲
dispatch (*/15 min) ──▶ per-tenant background functions ──▶ @owlypost/core
│
Anthropic API (metered per tenant, §5)
Resend (owlypost.com)
Stripe (checkout, webhooks, portal)2.1 Core consumption contract (untouched core + cloud bridge)
The core's pipeline functions are tenant-unaware by construction: they take a
Storage implementation as their parameter and read everything else (timezone,
language, LLM provider/models, email delivery) lazily from process.env at call time.
The cloud exploits exactly those two seams — nothing in the core changes:
// Core surface, as-is at the pinned commit (vendor/owlypost/lib):
runIngest(storage) // fetch, dedupe, extract, summarize (incl. LLM)
runDigest(storage) // the weekly editorial digest (incl. LLM + email)
synthesizeProfile(storage) // feedback → preference profile
runRetention(storage, now) // content retention sweeps
detectFeed(url, fetcher?) // feed auto-detection
interface Storage { ... } // ~50 CRUD methods over the eight content tables
// Cloud side:
TenantScopedStorage // implements Storage, every statement tenant-scoped
runWithTenantEnv(tenant, …) // sets the env the core reads (DIGEST_TIMEZONE,
// DIGEST_LANGUAGE, DIGEST_EMAIL_TO=owner, …) for the
// duration of one run, behind a process-wide mutex,
// and restores it afterwards
core-bridge/ (@owlypost/core) // the ONLY importer of vendor internals; re-exports
// the surface above + cloud-owned additions:
// applyFeedback(storage, …)Rules:
- The submodule is untouched: zero commits, zero patches, no build step. A core interface change surfaces as a failing typecheck on the cloud's submodule bump PR and is fixed in that PR (in the bridge or adapters), never by patching the core.
- Only
core-bridge/may import fromvendor/; the rest of the shell imports@owlypost/core. - Consumption is TypeScript source directly (Next uses
transpilePackages; tests run it via Node type stripping).core-v*tags on the public repo are optional human milestones. - Metering wraps the runs, not the calls (§5, metering v2): budget gate before each run, accounting from persisted/estimated usage after it.
3. Data model (shell)
Shell-owned tables (Supabase migration):
create table plans (
id uuid primary key default gen_random_uuid(),
key text unique not null, -- 'paid_v1', 'free'
name text not null, -- 'Early Owl', 'Free'
limits jsonb not null, -- see §5
is_active boolean not null default true
);
create table plan_prices ( -- prices decoupled from plans
id uuid primary key default gen_random_uuid(),
plan_id uuid not null references plans(id),
stripe_price_id text unique not null,
currency text not null default 'eur',
unit_amount integer not null, -- 395
active boolean not null default true -- checkout uses the active one
);
create table tenants (
id uuid primary key default gen_random_uuid(),
name text,
plan_id uuid not null references plans(id),
status text not null check (status in
('trialing','active','past_due','canceled','expired','comped')),
trial_ends_at timestamptz,
past_due_since timestamptz,
stripe_customer_id text unique,
stripe_subscription_id text unique,
digest_day smallint not null default 0, -- 0 = Sunday
digest_time time not null default '17:00',
timezone text not null default 'Europe/Amsterdam',
llm_kill_switch boolean not null default false,
created_at timestamptz not null default now()
);
create table tenant_members ( -- v1: exactly one owner per tenant;
tenant_id uuid not null references tenants(id), -- table keeps teams possible later
user_id uuid not null references auth.users(id),
role text not null default 'owner',
primary key (tenant_id, user_id)
);
create table usage_events (
id bigint generated always as identity primary key,
tenant_id uuid not null references tenants(id),
kind text not null check (kind in
('llm_call','manual_digest','scheduled_digest','ingest_run')),
model text,
input_tokens integer,
output_tokens integer,
cost_cents numeric(10,4),
created_at timestamptz not null default now()
);
create index on usage_events (tenant_id, created_at desc);
create table job_runs ( -- idempotency + observability
id bigint generated always as identity primary key,
tenant_id uuid not null references tenants(id),
kind text not null check (kind in ('ingest','digest')),
status text not null check (status in ('running','succeeded','failed')),
started_at timestamptz not null default now(),
finished_at timestamptz,
error text
);
create index on job_runs (tenant_id, kind, started_at desc);Core tables (categories, sources, items, digests, digest_items, feedback,
preference_profile) each get tenant_id uuid not null references tenants(id) plus
composite indexes (tenant_id, …).
3.1 Isolation, two layers
- RLS on every core table and shell table with user data:
tenant_id in (select tenant_id from tenant_members where user_id = auth.uid()). Covers every anon-key path from the browser. - Adapter scoping: background jobs use the service role, which bypasses RLS.
Therefore the cloud
StorageAdaptersets the tenant scope explicitly on every query. Direct Supabase queries to core tables outside the adapter are forbidden (lint/grep gate in CI). - Leakage test (CI, mandatory): seed two tenants, run the full pipeline for both, assert zero cross-tenant reads/writes at both the RLS layer and the adapter layer.
4. Subscription lifecycle
signup ──▶ Stripe Checkout (trial_period_days=14, payment method required)
└─▶ trialing ──14d──▶ active ──payment fails──▶ past_due ──grace──▶ expired
│ ▲ │ recovered ──▶ active
│ └── invoice.paid ──────────┘
└── user cancels ──▶ canceled (access until period end) ──▶ expired
Free plan: admin sets status 'comped' via Supabase dashboard (no Stripe objects, no admin UI in v1).Status semantics:
| Status | Access | Digests | Notes |
|---|---|---|---|
| trialing | full, trial limits (§5) | yes | until trial_ends_at |
| active | full | yes | |
| past_due | full + banner | yes, for 7 days [REVIEW] from past_due_since, then paused | Stripe smart retries run |
| canceled | full until period end | until period end | |
| expired | read-only + upgrade banner | no | data deleted after 30 days (deletion job, Phase 4) |
| comped | full | yes | admin-granted Free |
Stripe implementation:
- One Product; the active
plan_pricesrow drives Checkout. Never edit a Price: to change pricing, create a new Price, flipplan_prices.active. Existing subscriptions keep their Price. - Migrating existing subscriptions to a new price requires ≥30 days notice and a cancel option (terms clause, §9).
- Checkout:
mode=subscription,trial_period_days=14, dynamic payment methods per Decision 8 (nopayment_method_typesin the session). No promotion-code field (D-29). - Webhooks (signature-verified):
checkout.session.completed(link customer+subscription → tenant, statustrialing),customer.subscription.updated(sync status),invoice.paid(→active),invoice.payment_failed(→past_due, setpast_due_sinceonce),customer.subscription.deleted(→canceled/expired). - Stripe Customer Portal for payment-method updates and cancellation.
- Trial expiry without payment: subscription cancels via Stripe; webhook sets
expired.
5. Entitlements, quotas, metering
Single source of truth: getEntitlements(tenant) merges plans.limits with trial
overrides when status = 'trialing'. All enforcement calls this; nothing hardcodes limits.
plans.limits shape, defaults [REVIEW: all numbers]:
{
"max_feeds": 50,
"max_categories": 10,
"manual_digests_per_day": 3,
"monthly_llm_budget_cents": 150,
"trial_overrides": {
"max_feeds": 25,
"manual_digests_per_day": 1,
"monthly_llm_budget_cents": 75
}
}Enforcement:
assertQuota(tenantId, action)runs before any core invocation (manual digest button, add feed, scheduled job dispatch).- LLM budget (metering v2, untouched core): before every pipeline run (ingest summarizes, digest calls the model), check month-to-date
sum(cost_cents)fromusage_events(kindsllm_call+llm_estimate) against budget; at the cap block the run with a friendly message, never silently degrade. After every run, record usage: the digest call exactly (from the digest row's storedtoken_usage), summaries and profile synthesis as marked estimates (kind = 'llm_estimate', deliberately estimated high). Trade-off vs the original per-call check: a run that starts under the budget finishes even if it crosses the cap mid-run (overshoot ≤ one run). [REVIEW — Esmee: accepted precision loss, logged as P4-6.] llm_kill_switchon the tenant is the manual emergency stop.- Rate limit the manual digest button independently of the daily quota (protects against runaway loops).
Cost model note: at these caps the LLM cost per tenant stays roughly €0.50–€1.50/month against €3.26 net revenue, so margin survives worst-case usage.
6. Onboarding
Design principle: strong defaults, and the first digest within minutes, not after a week.
- Signup: email + password (Supabase), email verification.
- Stripe Checkout immediately after signup: every tenant has a payment method from minute one. Then the wizard.
- Wizard step 1 — Sources: choose one or more starter bundles (each: a category, 5–8 curated feeds, a prefilled preference-profile text so the first digest already has direction). Plus the free route: paste any URL (core feed detection). Bundle seed data: Claude Code drafts 4–6 bundles (e.g. Product & startups, AI & tech, Design, Dutch news) as a seed file [REVIEW: Esmee curates content before launch].
- Wizard step 2 — Rhythm: digest day + time, default Sunday 17:00 Europe/Amsterdam. Delivery to the verified account email only.
- Wizard step 3 — First digest: run an immediate ingest over the chosen sources (feeds carry history, so the past week arrives at once), generate the first digest, show it in-app and email it. Counts toward trial quota.
Migration path from self-host (the natural upgrade audience): feeds go in by URL (the
wizard's add-by-URL, and /sources afterwards), and the preference profile is
hand-editable text, so copy-paste works. OPML import/export was built in Phase 0 and
removed again in D-33 — no migration demand showed up; reintroduce it app-side if it
ever does.
7. Scheduling and job execution
dispatch(Netlify scheduled function, every 15 min): select tenants due for work — ingest every 6 h staggered by tenant hash; digest when tenant-localdigest_day+digest_timefalls in the window — wherestatus in ('trialing','active','comped')orpast_duewithin grace. For each, invoke the matching background function with{ tenantId, job }.ingest-tenant/digest-tenant(Netlify background functions, 15-min ceiling): build a tenant-scopedCoreContext, run the core pipeline, record ajob_runsrow.- Idempotency: dispatcher skips tenants with a
runningjob younger than a timeout; background functions marksucceeded/failedand never double-send a digest for the same period.
8. Email delivery
- Resend, sending domain
owlypost.com(SPF/DKIM/DMARC setup is a Phase 4 checklist item). - Sender:
Owly Post <owl@owlypost.com>. - Every digest email carries a "manage delivery / pause" link into the app.
9. UI, brand, legal
- The design system is Warm Companion, defined in
design.mdandbrandguidelines.md(locked 2026-07-16); those two files are the source of truth for colour, type, iconography and voice. Tokens live inapp/globals.css, primitives incomponents/ui.tsx. Hand-rolled — no component library. - Explicitly no owl mascot: no cartoon owl, no owl puns, no cute iconography. The wordmark is plain Fraunces until the logo lands. Warmth comes from copy and restraint, not decoration.
- Screens: auth, onboarding wizard, dashboard (sources by category, preference-profile editor, schedule, digest archive, billing status), digest web view.
- Empty states, error states and the digest header carry the brand personality — these are where it lives — through wording and typography.
- Digest email template: polished, consistent with the app.
- Legal pages
/termsand/privacy, drafted by Claude Code, must include: the price-change clause (changes to existing subscriptions announced ≥30 days ahead with the right to cancel), trial terms, and the privacy promise (Decision 13). [REVIEW: Esmee reviews; drafts are not legal advice.] - Public pricing copy explicitly says launch price.
10. Security
- Secrets only in Netlify/Supabase env config (Anthropic key, Stripe keys + webhook secret, Resend key). Never in the repo.
- Service-role key server-side only.
- Stripe webhook signature verification mandatory.
- Stripe stays in test mode until the Phase 4 launch checklist flips it.
- Backups: Supabase daily backups/PITR verified in Phase 4; restore procedure documented in the runbook.
11. Out of scope for v1
Teams / multiple users per tenant (schema already allows it later) · multiple digest
recipients · admin UI (Supabase dashboard suffices) · annual plan · USD / multi-currency
(datamodel supports adding a plan_prices row later) · public free tier · public API ·
search · company-email import.
12. Phased delivery plan
Phase 0 — Core intake (repo: owlypost-cloud; the core repo is not touched)
Historical note: Phase 0 was originally specified — and executed — as a workspace
extraction inside the open source core repo, complete with npm publishing. That
restructure was reverted in full on 2026-07-09 (owner decision, DECISIONS_LOG P4-6):
letting the cloud's needs reshape the open source project was the wrong trade, and the
core stays a flat single-user app. What Phase 0 means now:
audit the core as it is, pin it as the vendor/owlypost submodule, build the
core-bridge/ package (§2.1) and prove consumability (typecheck against the vendor
Storage interface; applyFeedback is a cloud-owned bridge addition).
Acceptance: the self-host app is untouched (zero commits on the core repo); the
cloud typechecks against the pinned commit; a submodule bump to another core commit
typechecks too (upgrade path proven).
Phase 1 — Multi-tenant foundation (repo: owlypost-cloud, private)
Scaffold Next.js app; Supabase schema (§3) incl. RLS; tenant-scoped StorageAdapter;
dispatch + background functions; seed two test tenants.
Acceptance: both tenants ingest and digest side by side on their own schedules; the
automated leakage test passes; CI gate proves no core-table query bypasses the adapter.
Phase 2 — Accounts, billing, access
Supabase auth flows (signup, login, reset); Stripe Checkout, webhooks, Customer Portal;
getEntitlements + metering + enforcement (§5); full status lifecycle incl. trial
expiry and past_due grace.
Acceptance: a stranger can sign up, pay (test mode) and get access; payment stopped →
digests stop; every quota path blocks with a clear message; webhook handlers unit-tested.
Phase 3 — Polish and onboarding (the reason to pay)
Design system over the interface; wizard (§6) incl. starter-bundle seed data; instant
first digest; dashboard; empty/error states with owl personality; digest email template;
/terms + /privacy drafts.
Acceptance: on staging, a new user goes from signup to a real first digest in ~2
minutes without help.
Phase 4 — Operational hardening and launch
DNS + Resend domain verification; monitoring and alerting (job failures, webhook failures, LLM budget anomalies); backups verified; 30-day data deletion job; runbook; launch checklist (Stripe live mode, real price). Acceptance: Definition of Done fully green; production live at app.owlypost.com.
13. Definition of Done (shell v1)
- A stranger can sign up, pay via Stripe (iDEAL or card), and get access
- Multiple tenants run on one instance with zero data leakage (proven by an automated test)
- A paying customer receives a polished digest per category on their schedule
- Payment stops → digests stop
- No tenant can run away with LLM costs: enforced caps on manual digests and monthly LLM budget
- The cloud has at least one clear one-sentence value over the bare core (§0)
- The open source core still runs unchanged as a single-user self-host
- One real paying customer runs stably
- Logging/monitoring shows whether digests run and payments arrive
14. Review points (everything Esmee adjusts before or during the build)
- Plan display name: settled on
Early Owl(D-28). Still oneplans.namerow, changeable any time without deploy. - Quota defaults and trial overrides (§5).
- Past-due grace period (7 days, §4).
- Starter-bundle contents (§6) — Claude Code drafts, Esmee curates.
/termsand/privacywording, incl. price-change clause (§9).- CONTRIBUTING/CLA wording in the core repo (Decision 15).