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/owlypostsubmodule plus a cloud-owned bridge package. Everything below that refers to a core package, alink: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/coreconsumed aslink:to the sibling checkout (until the first npm release — DECISIONS_LOG P1-1; replaced by the vendored submodule in P4-6); Netlify function structure innetlify.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 coreStorageinterface (~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 ontotenants.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 viaIntl, ingest every 6 h staggered by tenant hash, status eligibility incl. past_due grace (7 d, [REVIEW]) — fanned out by the scheduleddispatchfunction toingest-tenant-background/digest-tenant-background, guarded by aJOBS_SECRETheader. Idempotency:job_runsclaim/finish + a shell-side never-digest-twice guard. - Gates:
pnpm test:leak(mandatory) and the core-table grep gate insidepnpm lint; CI workflow runs lint → gate → typecheck → unit tests → local Supabase → leakage test → build.
Acceptance evidence (SPEC §12, Phase 1)
| Criterion | Evidence |
|---|---|
| Both tenants ingest and digest side by side on their own schedules | Leakage 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 passes | pnpm 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 adapter | scripts/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 gate | lint + 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
tenantscolumns (SPEC §3), weekly-only in v1; adapter maps it to core'sDigestSchedule, rejectsdaily. - P1-4
preference_profilekeyed per tenant (PKtenant_id). - P1-5 anon gets zero table grants (permission denied beats empty result);
usage_events/job_runsare 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 = 7constant in dispatch untilgetEntitlementslands in Phase 2 ([REVIEW] with SPEC §4's grace default). - P1-8 Never-digest-twice enforced via
job_runslookback (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 patchesglobalThis.fetchinstead (P4-6). - P1-10
usage_eventsrecords 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 thelink: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.