Owly Post
About Owly PostHow it was made

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)

#TopicDecision
1Business modelOpen core (AGPL, free self-host) + paid hosted shell. No fork: shell consumes the core.
2Core consumptionThe 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.
3TenancyMulti-tenant, single instance. Shared schema with tenant_id on every core table, enforced by RLS and an explicitly scoped storage adapter (defense in depth).
4HostingNetlify (app + scheduled/background functions). Dispatcher pattern for per-tenant jobs.
5Database + authSupabase, region Frankfurt (eu-central-1). Supabase Auth, email + password.
6LLM costsIncluded in the price ("do it for you"). Operator-owned Anthropic key. Hard caps per tenant (§5).
7Trial14 days, payment method required upfront via Stripe Checkout. Trial is a status, not a plan.
8Payment methodsStripe 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.
9Price€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).
10PlansOne 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.
11DomainsApp: 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).
12DeliveryDigests go to the verified account email only in v1.
13Privacy promiseData resides in the EU (Frankfurt). No training on customer data. Export on request. Data deleted 30 days after expiry.
14Core stabilityThe open source core must keep running unchanged as a single-user self-host. Regression-tested in Phase 0.
15LicensingEsmee 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):

  1. 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.
  2. When the core changes, those changes can roll out to the cloud: one submodule bump PR, with all gates proving it safe.
  3. 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 from vendor/; 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

  1. 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.
  2. Adapter scoping: background jobs use the service role, which bypasses RLS. Therefore the cloud StorageAdapter sets the tenant scope explicitly on every query. Direct Supabase queries to core tables outside the adapter are forbidden (lint/grep gate in CI).
  3. 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:

StatusAccessDigestsNotes
trialingfull, trial limits (§5)yesuntil trial_ends_at
activefullyes
past_duefull + banneryes, for 7 days [REVIEW] from past_due_since, then pausedStripe smart retries run
canceledfull until period enduntil period end
expiredread-only + upgrade bannernodata deleted after 30 days (deletion job, Phase 4)
compedfullyesadmin-granted Free

Stripe implementation:

  • One Product; the active plan_prices row drives Checkout. Never edit a Price: to change pricing, create a new Price, flip plan_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 (no payment_method_types in the session). No promotion-code field (D-29).
  • Webhooks (signature-verified): checkout.session.completed (link customer+subscription → tenant, status trialing), customer.subscription.updated (sync status), invoice.paid (→ active), invoice.payment_failed (→ past_due, set past_due_since once), 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) from usage_events (kinds llm_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 stored token_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_switch on 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.

  1. Signup: email + password (Supabase), email verification.
  2. Stripe Checkout immediately after signup: every tenant has a payment method from minute one. Then the wizard.
  3. 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].
  4. Wizard step 2 — Rhythm: digest day + time, default Sunday 17:00 Europe/Amsterdam. Delivery to the verified account email only.
  5. 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-local digest_day+digest_time falls in the window — where status in ('trialing','active','comped') or past_due within grace. For each, invoke the matching background function with { tenantId, job }.
  • ingest-tenant / digest-tenant (Netlify background functions, 15-min ceiling): build a tenant-scoped CoreContext, run the core pipeline, record a job_runs row.
  • Idempotency: dispatcher skips tenants with a running job younger than a timeout; background functions mark succeeded/failed and 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.

  • The design system is Warm Companion, defined in design.md and brandguidelines.md (locked 2026-07-16); those two files are the source of truth for colour, type, iconography and voice. Tokens live in app/globals.css, primitives in components/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 /terms and /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)

  1. Plan display name: settled on Early Owl (D-28). Still one plans.name row, changeable any time without deploy.
  2. Quota defaults and trial overrides (§5).
  3. Past-due grace period (7 days, §4).
  4. Starter-bundle contents (§6) — Claude Code drafts, Esmee curates.
  5. /terms and /privacy wording, incl. price-change clause (§9).
  6. CONTRIBUTING/CLA wording in the core repo (Decision 15).

On this page