Owly Post
About Owly PostHow it was madePhase reports

Phase 1: multi-tenant foundation

What phase 1 delivered, with acceptance evidence, deviations and open items

Status: ✅ complete — all Phase 1 acceptance criteria met (evidence below). Awaiting Esmee's go for Phase 2. Repo: fresh private git repo, 5 commits. Plus one small commit in owlypost (feat(core): injectable fetcher on CoreContext, on phase-0/core-extraction).

Editor's note (added for publication). This report describes the state on 2026-07-08. On 2026-07-09 the core-consumption model was reversed: the Phase 0 extraction was reverted, the core repo went back to untouched, and the cloud switched to a pinned vendor/owlypost submodule plus a cloud-owned bridge package. Everything below that refers to a core package, a link: dependency or a commit in the core repo was superseded that day — see DECISIONS_LOG P4-6. The tenancy work itself (adapter, RLS, dispatcher, leakage test) survived the change unchanged.

What was built

  • Scaffold: Next.js 15 App Router (TS strict), pnpm 11.6.0, @owlypost/core consumed as link: to the sibling checkout (until the first npm release — DECISIONS_LOG P1-1; replaced by the vendored submodule in P4-6); Netlify function structure in netlify.toml.
  • Database (local Supabase via CLI; Frankfurt connects at deploy): SPEC §3 shell tables; all core content tables with tenant_id not null, per-tenant unique/exclusion constraints, composite indexes; RLS on everything (membership-based via a security-definer helper); explicit grants (anon: none at all); dev seed with Roost/Free plans and two test tenants on different schedules/timezones with sign-in-capable local auth users.
  • Tenant-scoped StorageAdapter (lib/adapters/tenant-storage.ts, the only file allowed to touch core tables): implements the full core Storage interface (~40 methods) over the shared service-role pool. Every statement carries an explicit tenant predicate; user-supplied ids go through ownership guards (insert…select); joins are scoped on both sides; digest schedule maps onto tenants.digest_day/digest_time, weekly-only.
  • Per-tenant CoreContext (lib/tenant-context.ts) with injectable llm/mailer/fetcher; Anthropic via lazy provider (metering wrapper comes with Phase 2).
  • Dispatcher (SPEC §7): pure 15-minute window selection (lib/jobs/dispatch.ts) — tenant-local digest moment via Intl, ingest every 6 h staggered by tenant hash, status eligibility incl. past_due grace (7 d, [REVIEW]) — fanned out by the scheduled dispatch function to ingest-tenant-background / digest-tenant-background, guarded by a JOBS_SECRET header. Idempotency: job_runs claim/finish + a shell-side never-digest-twice guard.
  • Gates: pnpm test:leak (mandatory) and the core-table grep gate inside pnpm lint; CI workflow runs lint → gate → typecheck → unit tests → local Supabase → leakage test → build.

Acceptance evidence (SPEC §12, Phase 1)

CriterionEvidence
Both tenants ingest and digest side by side on their own schedulesLeakage suite: full pipeline (ingest 3 items → 3 summaries → digest ready → item + section feedback) for Alpha (Sun 17:00 Europe/Amsterdam) and Beta (Wed 09:00 America/New_York), interleaved; selectDueTenants proven to pick exactly the right tenant in each schedule window (test "each tenant is dispatched on its own schedule")
Automated leakage test passespnpm test:leak: 9/9 green — adapter-layer cross-reads return null/[], cross-writes throw and write nothing, six DB join invariants zero, RLS layer (each member sees only own rows; cross-tenant insert rejected by WITH CHECK; foreign update affects zero rows), anon sees nothing (no grants), scheduler idempotency (claim gate + never-digest-twice)
CI gate proves no core-table query bypasses the adapterscripts/gate-core-table-queries.ts in pnpm lint (comment-stripped scan for SQL/supabase-js access to the 8 core tables outside lib/adapters/ and tests/); locally: core-table gate: clean; wired into CI
Full gatelint + gate ✅ · typecheck ✅ · unit tests 12/12 ✅ · leak 9/9 ✅ · next build

Determinism: the pipeline runs with a fake LLM (schema-driven answers, fixed token usage) and a fake fetcher (RSS fixtures) via the new core extension ctx.fetcher — no network, no API keys, no flakiness in CI.

Deviations (full entries in DECISIONS_LOG.md)

  • P1-1 link: dependency on the sibling core checkout until the first npm publish; CI checks out both repos side by side (needs a deploy key once CI runs on GitHub). Superseded by P4-6: submodule + workspace:*, no deploy key needed for a public repo.
  • P1-2 Local Supabase (CLI) is the Phase 1 database; the Frankfurt project is connected at deploy time — no production secrets touched this phase.
  • P1-3 Digest cadence on tenants columns (SPEC §3), weekly-only in v1; adapter maps it to core's DigestSchedule, rejects daily.
  • P1-4 preference_profile keyed per tenant (PK tenant_id).
  • P1-5 anon gets zero table grants (permission denied beats empty result); usage_events/job_runs are service-role-only (no grants, no policies).
  • P1-6 Background functions guarded by JOBS_SECRET; dispatch never executes pipeline work itself.
  • P1-7 PAST_DUE_GRACE_DAYS = 7 constant in dispatch until getEntitlements lands in Phase 2 ([REVIEW] with SPEC §4's grace default).
  • P1-8 Never-digest-twice enforced via job_runs lookback (23 h) — shell-only, keeps the golden rule intact.
  • P1-9 Core gained ctx.fetcher (public repo commit) — an extension point, not a copy, per golden rule 1. Reverted with the rest of the Phase 0 core work on 2026-07-09; the leakage test now patches globalThis.fetch instead (P4-6).
  • P1-10 usage_events records run-level events now; per-LLM-call cost metering arrives with Phase 2 enforcement.

[REVIEW] items touched

  • Plan name "Roost" used in the seed (SPEC §1 decision 10).
  • Quota defaults + trial overrides seeded verbatim from SPEC §5.
  • Past-due grace 7 days as dispatch constant (SPEC §4).

Open items

  • Netlify deploy itself (site, env vars, real schedule) is intentionally not part of Phase 1 acceptance; the function structure and logic are ready and locally tested.
  • First npm publish of @owlypost/core@0.1.0 — needed before the link: dependency can become a version pin (suggest doing this at the start of Phase 2). Never happened: publishing was dropped (P4-5) and then the whole extraction was reverted (P4-6).
  • A CI deploy key must be added when this repo gets a GitHub remote. Obsolete after P4-6 — a public submodule clones without credentials.
  • Phase 0 leftover: Esmee's self-host instance still runs the pre-workspace image. Moot after the revert: the core went back to exactly that image.

Go / no-go

Phase 2 (accounts, billing, access) is not started — waiting for Esmee's explicit go.

On this page