Three surfaces, one database, one authorization model.
Starwood Community is a resident-community platform: a resident web portal, a property-management
admin portal and a native mobile app, sharing one Postgres database. The defining choice is that
authorization lives in the database, not in the applications — every client talks to the same
API under the same row-level security policies, so a new surface cannot invent a new way to leak data.
figures measured at main · 02758f72August 30, 2026Re-check them: bash dev_workflow/architecture/verify-figures.sh
01The shape of the system
Three deployable clients and three private shared packages in one npm-workspaces monorepo,
task-run by Turborepo, plus one managed backend. Types, domain logic and data access live in the packages,
so nothing is duplicated across apps.
3client surfaces
3shared packages
224Postgres tables
798RLS policies
263SQL migrations
129klines of app TS/TSX
The mobile app does not depend on @starwood/ui — that package is web-only. It shares
types, domain logic and data access, and has its own React Native primitives.
One design system, two rendering models
@starwood/ui carries the web components and the token set. The product renders dark by default;
light is applied by putting data-theme="light" on the document element, and every light rule is
scoped to that attribute so the dark path is never touched. A small bootstrap script runs before first paint, so
a resident who chose light mode does not see a dark flash on load. React Native has no CSS, so the mobile app
cannot consume that stylesheet — it carries an equivalent palette through a theme context instead. Two
implementations, one token vocabulary.
Contrast is measured, not assumed. A scanner in the design-review tooling composites real backgrounds
through ancestors and alpha, then reports WCAG failures for every screen in both themes. It currently records
zero failures — alongside 106 gradient-background cases it cannot judge automatically and flags
for a human rather than counting as passes. The honest reading is "nothing measurable is failing", not
"everything is verified".
02The four choices that shaped everything else
Each of these is reversible in principle, but each has downstream consequences worth stating plainly.
1 · Authorization in the database, not the application
Every table carries row-level security bar one documented exception. A client holds a user JWT and talks to PostgREST directly;
Postgres decides what that user may read or write. Where a rule needs more than a row predicate — cross-table
checks, aggregate counts that must not expose identities, atomic multi-step writes — it becomes a
SECURITY DEFINER function with explicit REVOKE/GRANT.
Why it pays: three surfaces plus any future integration inherit the same rules. A bug in
one client cannot widen access. What it costs: policies are SQL, so they need SQL-literate review and
database-level tests; a policy change is a migration, not a hotfix; and because a rule lives in exactly one
predicate, rewriting that predicate can delete it silently — see 5.10 for the time that happened.
2 · One monorepo, shared packages, no shared runtime
Types, validators and pure domain logic live in @starwood/shared and are imported
from source — the workspace packages have no build step in development. Data access lives in one place.
Apps stay thin.
Why it pays: a domain rule (say, how duplicate write-in poll answers are merged) is
written once and unit-tested without a database, then reused by all three clients. What it costs: a
change to a shared package can break three apps at once, so the type-check and test sweep has to cover the
whole workspace.
3 · Supabase as the backend, rather than a bespoke API tier
There is no hand-written API server. PostgREST exposes tables and RPCs, GoTrue handles identity
and MFA, Realtime streams row changes, Storage holds user files, and pg_cron runs scheduled work
inside the database.
Why it pays: no API layer to write, secure, deploy or keep in sync with the schema —
a meaningful saving at this feature count. What it costs: business logic that would live in a service
layer lives in Postgres functions instead, and the platform is a genuine dependency. Portability rests on the
fact that everything is standard Postgres plus a thin auth contract.
4 · Property is the tenancy boundary
The chain is organizations → properties → profiles. 153 of the 224 tables carry a
property_id, and the standard policy compares it to the caller's own
profiles.property_id. Residents see their building; property managers manage their building;
super admins cross buildings.
Why it pays: one predicate, applied consistently, gives multi-tenant isolation without
separate databases or schemas. What it costs: a table that forgets property_id silently
opts out of tenancy, so new tables need review — which is what the database-level test suite is for.
03How a request is actually authorized
This is the part worth understanding in detail, because it is the same for every surface and every
feature.
Path B exists because some rules are not row predicates: aggregate counts that must not reveal who
voted, multi-row writes that must be atomic, or visibility that depends on another table's configuration.The left column is why these were chosen and the right is what has actually been paid so far. A CTO
should read the right column first: it is the part that does not appear in an architecture diagram.
Roles
Role
Scope
Enforced by
resident
Their own rows, plus what their property shares
RLS predicates on property_id and auth.uid()
property_manager
Everything within their property
RLS role check + admin-portal middleware
super_admin
All properties
Dedicated FOR ALL policies
system_admin
Operational surfaces such as the log viewer
Admin-portal middleware + RPC grants
Both Next.js apps run edge middleware that rejects unauthenticated requests before a page renders, and the
admin portal additionally requires one of the three privileged roles. That middleware is a
convenience, not the boundary — the same check exists in the database, so a request that skips
the app entirely is still refused.
One deliberate exception. 223 of 224 tables use RLS. The exception, app_logs, holds no
grant at all for anon or authenticated — only postgres and
service_role — so there is nothing for a policy to filter. Clients write to it through
insert_app_log, and reading is system_admin only, through get_app_logs.
Locked by grant rather than by policy: stricter than RLS, not weaker.
Staff tenancy — how a manager reaches a second property
Everything above describes a resident, whose tenancy is a single column. Staff are the harder case: a regional
manager legitimately needs several buildings, and the mechanism that grants that is the one place where widening
somebody’s reach is a normal operation rather than an attack.
This is the one place the tenancy boundary is designed to move, so it is worth seeing that moving it
takes an admin, an audit row and a function — and that the table itself refuses everyone else at the grant
layer, before any policy is consulted.
How 798 policies stay coherent
The obvious objection to putting authorization in the database is that it does not scale as a discipline —
hundreds of hand-written predicates, each a chance to get one wrong. Here is the actual shape of them, measured
rather than asserted, along with the places the convention has frayed. One number below is worth reading twice:
223 tables carry RLS but only 222 have a policy. The odd one out is property_lookup_attempts, which
has RLS on and no policy at all — the strictest setting there is, since nothing matches. Only the
SECURITY DEFINER rate limiter behind signup touches it.
Every number here is re-derived from the catalogs by
verify-figures.sh rather than maintained by hand — including the frayed edges, which
is the point: a convention you cannot measure is a convention you are guessing about.
04The data model
224 tables is too many to draw. What matters is that they all hang off the same two hubs, and that
every feature cluster repeats the same shape.
Two hubs carry the model: profiles answers “who wrote this”, properties
answers “who may see it”. Almost every other table joins to both.
One cluster in full
Every feature cluster is built the same way, so one is worth reading closely. Polls carry the tenancy key and
the author on the parent row, keep choices and free text in separate children, and push the rules that must not
be bypassed into constraints rather than application code.
Nothing here is enforced only in TypeScript. Each rule is a constraint, an index or a trigger, so a
crafted REST call meets the same wall the UI does.
Schema at a glance
Object
Count
Note
Tables
224
223 with row-level security enabled
RLS policies
798
The authorization surface
Functions
433
Of which 254 are SECURITY DEFINER
Triggers
194
Invariants the client cannot skip
Check constraints
1,244
Validation that survives a crafted API call
Foreign keys
556
On delete: 311 cascade, 220 set null, 19 restrict, 6 no action
Indexes
815
154 of them partial — filtered reads on hot paths
Enum types
216
Status and category vocabularies
Migrations
263
57,450 lines of SQL, applied in order
The 224 tables are not 224 unrelated things. Grouped mechanically by name family, 37 families of three or more tables account for 154 of them, and only 34 are one-offs. The clusters drawn above are a handful
of those families; the long tail is compliance and operations, built the same way.
Validation is deliberately duplicated: the same rule exists as a Zod schema in
@starwood/shared (for a good error message in the UI) and as a constraint or trigger in Postgres
(because the REST API is reachable without the UI). The database is the one that counts.
05Key flows
Thirteen processes worth tracing end to end, because they are the ones where a mistake would be
expensive. Each pairs a rule that must hold with the place the system actually holds it.
Flow
The rule that must hold
Where it is enforced
Data model
5.1 Booking an amenity
Two people cannot take the last slot
Row lock in the RPC + a trigger every caller meets
✓
5.2 Maintenance request
A status cannot skip or go backwards
BEFORE UPDATE trigger, one audited bypass
✓
5.3 Live and timed work
Scheduled things happen with nobody watching
Realtime channels + four pg_cron jobs
—
5.4 Signing in
A skipped app cannot mean skipped checks
Middleware for UX, RLS for the boundary
—
5.5 Event RSVP
A full event waitlists rather than oversells
Row lock, capacity trigger, FIFO promotion
✓
5.6 Notifications
A resident only hears what they asked for
Trigger per event kind + a preference gate
✓
5.7 Joining a property
A stranger can look up a building, not harvest it
Narrowed anon RPC + sliding-window rate limit
—
5.8 Private messages
Only the two people in a thread — plus staff
RLS on both tables, and a path-bound attachment
✓
5.9 Moving a resident out
One flag has to settle five tables, auditably
A trigger on profiles with a scoped bypass
—
5.10 Seeing a post
Four separate rules decide one row's visibility
A single policy — and the way it once lost a clause
✓
5.11 Points and rewards
Points are money, so the count must never drift
Append-only ledger, unique index, locks where sums rule
✓
5.12 The guard that isn’t
An authorisation check that never fires
NULL comparisons, SECURITY DEFINER, and two revokes
—
5.13 Feature flags
“Off” must mean hidden and unreachable
One shared route map, three surfaces, two defaults
✓
5.1 A write that must not be wrong — booking an amenity
Two residents tapping the last slot at the same moment must not both get it. The booking RPC is the front
door, but it is not the only door: row-level security also permits a direct insert from the
REST API. So every rule is enforced twice — once in the RPC for a clean error message, once in a trigger that
no caller can skip.
The row lock at step 4 is what makes the capacity check trustworthy: without it, two callers could
both read "one seat left" before either wrote.
The data behind it — what a booking is, and which parts of it can never change
Two tables, and an unusually opinionated set of rules about them. Worth reading because the rules are the
product: an amenity is not a calendar, it is a capacity with a duration policy attached.
Six of the fifteen columns cannot be changed once written. That is what makes the QR token usable as a
check-in credential and the audit trail worth reading — a booking’s history is append-only in everything
that matters.
5.2 A lifecycle that must not skip — a maintenance request
A work order is the one place where residents and staff share a record over days. Residents open one and read
their own; only a property manager in that property can move it, and the database refuses any move that is not
the next step.
Residents may create and read their own; only a property manager in the same property may update
one. Both rules are row-level security policies, not screens.The satisfaction fields exist because the loop only closes when the resident says it did — the
rating is captured on the same row as the work.
5.3 Work that happens with nobody watching
Two things must keep running when no browser is open: pushing changes to people who are looking, and doing
timed work for people who are not. Both live in the database rather than in a separate service.
Scheduled publishing lives in the database on purpose: a post must go out at its appointed minute
whether or not anyone is using the app. The announcement job takes its rows with
FOR UPDATE SKIP LOCKED, so a slow run overlapping the next minute cannot double-publish.
5.4 Getting in the door
Two checks run on every request, and only one of them is the boundary.
Multi-factor is opt-in: a resident enrols TOTP from their profile, and an admin who has enrolled
one cannot reach the portal on a single-factor session.
5.5 A queue that manages itself — event RSVP
An event with a capacity has to do three things at once: refuse to oversell, hold the people who missed out,
and give a freed seat to whoever waited longest — without anyone pressing a button.
Nothing in this flow needs an operator. A cancellation at 2am promotes the next person and the
count stays exact.The refund and tier-deletion columns exist because a paid tier can be withdrawn after people have
bought it — the row has to remember what it was worth.
5.6 How a domain event becomes a notification
Fifteen tables can notify a resident. None of it is written by application code — each is a
trigger on the table where the thing actually happened, so a notification cannot be forgotten by a client and
cannot be forged by one.
Putting the fan-out in triggers is what makes "you were notified" a property of the data rather
than of whichever client happened to perform the write.
The data behind it — 46 event types, 10 switches, and the 16 that answer to none of them
The flow above is only half the story. The other half is how many kinds of notification exist, how few controls
a resident actually has over them, and which ones ignore those controls entirely.
Both halves of this figure are the same lesson from opposite directions: the preference switch only
means something where a WHEN arm and a caller-side check both exist, and neither the
schema nor the type constraint can tell you where they don’t.
5.7 The one thing a stranger may ask — joining a property
Signup has a chicken-and-egg problem: someone with no account must resolve a building before they can create
one. That means an endpoint reachable by anon, which is the only such surface in the system and
therefore the one worth reading closely.
This is the only function in the system granted to anon. Everything else requires a
session, which is why it carries its own rate limit rather than relying on one.
5.8 Private messages, and who can actually read them
Resident-to-resident chat is the most privacy-sensitive data in the product. Three tables and one storage
rule decide who sees it.
The moderation policy is a deliberate product decision recorded in the schema — but residents are
not told about it anywhere in the app. That is a disclosure question, not a technical one.
5.9 One flag, five tables — moving a resident out
Deactivating a resident is a single boolean write. Everything that has to follow from it happens in one
transaction, in the database, so a half-finished move-out cannot exist.
The bypass is switched on and off inside the same function, so the one place the status rules bend
is bounded to these statements and recorded in the activity log.
5.10 Four rules in one predicate — whether you see a post
The feed is where the authorization model is under the most pressure: tenancy, moderation, drafts and
scheduling, and private channels all decide the same row. They are not four checks in the application. They are
one policy, and reading it is the fastest way to understand both the strength and the cost of this design.
This is the strongest argument on the page for testing policies as behaviour rather than reviewing
them as code: every rewrite compiled, passed review and shipped.
5.11 When the data is money — points and rewards
Residents earn points for taking part and spend them on gift cards funded from the property's own budget.
That makes the ledger financial data, and it is modelled the way an accountant would rather than the way a
feature usually is.
The idempotency guard never broke during the race, because it is a real unique index rather than a
read. That is the distinction worth carrying into any new feature that counts something.
5.12 The guard that isn’t — a NULL-comparison authorisation class
Fixing 5.11 raised an obvious question: if two functions trusted an unauthenticated caller, how many others do?
Sweeping the schema for the shape found four more, all confirmed against a live PostgREST using the public
anon key — the key that ships inside the client bundle — with no Authorization
header at all. Migration 262 closes them.
After migration 262 an anonymous call to all four returns 401 permission denied at
the grant, before the body runs, while an authenticated resident is unaffected. Both were re-probed to confirm it.
5.13 One row of booleans, three surfaces — per-property feature flags
Not every property wants every feature. A flag on property_settings switches one off,
and the interesting part is what “off” has to mean: hidden in the navigation of two different apps
and unreachable if somebody types the URL. Getting those to agree is the whole design.
The distinction in the last box is the one worth pressing on: nine of these flags decide what a
property is offered, not what its data permits, and that is a legitimate choice. The tenth needed to be a
real gate, and only became one after somebody proved it was not.
06Runtime and delivery
Realtime push
Feed, messages and unread counts subscribe to postgres_changes channels, so updates arrive
without polling. Channels are authorized with the same user JWT the REST calls use.
Scheduled work pg_cron
Four jobs run inside the database — publishing scheduled posts, archiving expired ones and running
announcement schedules every minute, plus a weekly digest on Monday mornings. No external worker to operate.
File storage 2 buckets
community-images is public with a 5 MB cap. message-attachments is private:
its policies require the first segment of an object's path to equal the uploader's user id, so nobody can
write into another resident's folder, and reads additionally require a message that actually carries the file.
Identity GoTrue
Email/password, with optional TOTP multi-factor that residents enrol from their profile.
profiles is 1:1 with auth.users; an admin who has enrolled a factor must clear a
step-up challenge before the portal opens.
How a file is authorized
Storage is the one place where the authorization rule is written against a string rather than a
column, so it is worth seeing in full. The two buckets take deliberately opposite postures, and the private
one’s read rule is more subtle than it looks.
Six policies, two buckets, and one idea: let the file system’s own naming carry the ownership
claim, then borrow the rest from the table that already knows who may see what.
Environments and ports
Surface
Local
Planned production target
Resident web
:3000
Vercel or equivalent Node host
Admin portal
:3001
Vercel or equivalent Node host
Mobile
:8081 dev · :8082 web preview
EAS build → App Store / Play
Supabase APIKong
:54321
Hosted Supabase project
Postgres
:54322 · pooler :54329
Managed, transaction pooling
Studio / Mail
:54323 · :54324
Supabase dashboard
Local development runs the real Supabase stack under Docker — twelve containers, including Storage,
Realtime, GoTrue and the mail catcher — so the local column is a genuine mirror of the hosted one rather than
an approximation. That is why the integration suite can assert on storage policies and realtime channels at all.
One trap worth knowing before you debug anything. A no-Docker fallback exists at
dev_workflow/architecture/local-supabase.sh — native PostgreSQL, PostgREST, and a small Node
process standing in for /auth/v1 — for machines that cannot run Docker. It must never run
alongside the real stack. It binds 127.0.0.1:54321 on IPv4 while Kong binds the IPv6 wildcard, so
when both are up the fallback silently wins and becomes the database the apps talk to. That has already
produced a feed showing ten polls where the real database had three, a “realtime is broken” false
alarm (the fallback has no Realtime), and a storage test failing for want of a Storage API. The preflight script
now fails loudly when the port is shadowed, which is the only reliable way to notice.
No production environment exists yet. The right-hand column is the documented plan
(deployment_plan.md): hosted Supabase, Vercel for the two web apps, EAS with TestFlight and Play
internal testing for mobile. Both web apps already expose api/health for hosted smoke checks. The
DigitalOcean droplet behind starwood-community.com serves the stakeholder preview over nginx with
Let's Encrypt TLS — a static host, not an application environment.
07Operations, observability and data rights
The parts that matter once real residents are on the platform rather than seed data.
Structured event log app_logs
Both a client (logEvent) and a server (logEventServer) helper write structured
events — action, entity, property, level, error — from every surface into one table. Reading it is tightly
held: the viewer function refuses any role but system_admin, and a separate function returns a
resident only their own entries. Nothing else can touch the table at all. This is how a production incident
gets diagnosed today.
Notifications in-app
A notifications table fans out from domain events, with per-category
notification_preferences each resident controls. Delivery is in-app; push registration is
scaffolded but not yet provisioned (see gaps).
Resident data rights GDPR-shaped
A resident can export their data and delete their account from their own profile. Deletion runs through
delete_own_account(), which re-checks the account password before it will proceed and is
revoked from anon — so a stolen session alone cannot destroy an account.
Load shedding middleware
The admin portal's middleware caps concurrent authentication work and sheds beyond it rather than
queueing, so a burst of traffic degrades predictably instead of exhausting the auth path. Postgres sits
behind a transaction pooler.
What “delete my account” actually does
Both halves of a resident’s data rights are implemented, and the erasure half is more interesting than it
looks: the behaviour is not written in a function, it is spread across 265 foreign keys, and one of them stops the
whole thing.
Erasure behaviour is emergent here — nobody wrote “what happens to a departing resident”
in one place, and the answer is the sum of 265 independent decisions made one migration at a time. That is worth
knowing before someone exercises the right.
Schema changes are forward-only. 263 numbered SQL migrations apply in order; there are no down
migrations. A correction is written as a new migration that supersedes the old one — which is why several
files in the sequence exist purely to tighten a policy or a constraint that shipped too loose.
08How correctness is defended
Four layers, each catching what the layer above cannot.
Where a rule can live
Every section above is really one question asked repeatedly: given a rule, which layer should hold it? The
layers are not interchangeable — each can express something the one above cannot, and each costs something
the one above does not. This is the whole schema counted by where its rules ended up.
Read top to bottom this is a ladder of increasing power and decreasing safety. The discipline the
page argues for is to take the highest rung the rule fits on — and to notice that only 29 rules in the
entire system genuinely needed the bottom one.
The failure this document is organised against
One pattern accounts for most of what went wrong in this codebase, and it is not bugs in features. It is
a check that passes on the exact condition it was written to catch. Nine of them surfaced in a single
day, written by six different people, and none announced itself — each looked like working machinery
right up to the moment somebody measured it.
Six authors, one repository, one day. The count is not a claim about this team — it is what
happens whenever a check is written and never made to fail on purpose.
The same defect points both ways, and the second direction is harder to see. Every row above is a check
that could not fail. The companion mistake is a check that does fail and gets explained away —
or one that fails again for a new reason and is read as confirmation. Both happened here on the same day, in
opposite directions, and the common error is the same: treating a second observation as independent when
it is not.
A real bug attributed to a bad environment. Two end-to-end failures survived the removal of every
environmental cause, and this page still called them a navigation timeout for a day. They were the
authError || !user defect in 5.4. The environment was genuinely broken the whole time, which
is what made the excuse so durable — every explanation offered was true.
An environmental artifact nearly attributed to a real bug. An integration run showed a capacity
trigger failing to block a waitlist→going update, which would be a genuine bypass. Re-running that
test alone failed again, which looked like confirmation. It was not: the test depends on the one before it
to create the row it updates, so in isolation the UPDATE matches nothing, returns no error,
and fails for an unrelated reason. The full block passes.
Isolation is not a neutral instrument. Re-running a failure on its own is the standard way to separate
code from environment, and in an order-dependent block it manufactures a different failure wearing the same
clothes. What settles the question is a signal that can only mean one thing — failures moving to
different tests across runs is environmental in a way no single re-run can establish.
Layer
Scale
What it catches
Unit & component Vitest · React Testing Library
433 files 9,539 tests
Pure domain logic and rendering, with no database. Fast enough to run on every change.
Source contract assertions over source text
within the above
That a security guard still exists — for example that a query goes through the hardened RPC and not a direct table write. Cheap insurance against a well-meaning refactor.
Integration Vitest against local Supabase
37 files 1,286 tests
RLS behaviour through the real REST API, as each role. This is where tenancy and privacy rules are proven.
End-to-end Playwright
3 specs · 3 projects 47 tests
Full user journeys on both web surfaces, plus a concurrent-load project.
A worked example. Polls recently gained free-text answers with an admin toggle to hide who wrote them.
Hiding the author in the read path was not enough: a write-in answer is backed by a vote for a visible
“Other” option, and vote rows were readable property-wide — so one extra query re-identified the author.
The fix hides individual votes only on polls that asked for anonymity, and moves tallies into a
counts-only function. It is covered by 87 assertions executed against a throwaway Postgres built from the real
migrations; deleting the fix makes them fail with the leaked user id, which is how we know the tests bite.
09Known gaps
Stated plainly, because they are the things worth deciding on.
CI covers three layers of four, and installs without a lockfile..github/workflows/ci.yml
runs on every push and pull request: type-check and all seven unit suites, a full replay of every migration
plus seed.sql against a real PostgreSQL, and — on a dedicated runner — the whole
browser suite against a real Supabase started from those same migrations. That is more automation than this
page could claim a day ago. The gap left is the one that matters most: the integration suites, which are what
actually prove the tenancy and privacy rules, still run nowhere but a laptop. The browser job shows the
blocker is surmountable — it already starts Supabase on the runner — so the remaining work is
pointing a job at npm run test:integration rather than solving anything new. And because package-lock.json is
gitignored, CI installs with npm install rather than npm ci and resolves
dependencies fresh on every run, so a transitive update can turn a build red with no change to this
repository. That is not hypothetical: it is why the workflow pins Node 22 — realtime-js began
requiring a native WebSocket, and only a stale local node_modules was hiding it.
Mobile release path is not wired up.apps/mobile/eas.json does not exist and the Expo
project id in app.json is empty, so no build can be produced yet. Push-notification registration
is scaffolded but not provisioned for production.
Where each layer actually stands. The suites are 9,539 unit tests across 433 files,
1,286 integration tests across 37 files, and 47 end-to-end tests. The unit layer could not answer the
same way twice, and the cause turned out to be neither contention in the abstract nor product behaviour. The
first test to import a module pays the whole cost of transforming it and its dependency graph; under a full
parallel sweep that one-off cost ran past vitest's 5-second default and failed whichever test was scheduled
first, in whichever file lost the race. Warming one file only moved the failure to the next, so the ceiling
itself was the problem. Mobile showed it first — 2, 2 and 0 failures across identical runs, then six
consecutive clean sweeps once the timeout was raised. The instructive part is what came after: consumer-web
passes in full when run on its own and later returned 10 failures inside a loaded sweep,
every one “timed out in 5000ms” and each in a different file. Same class, different suite, and the
fix had only been applied to mobile. All seven suite configurations now carry it, and a sweep of all seven is
green. The seventh is a re-run rather than an addition, which is worth stating because summing the seven is
the obvious thing to do and gives the wrong answer:
vitest.source-contracts.config.ts selects 52 files that the mobile config’s own
lib/__tests__/** glob already covers, so its 993 checks are 993 of the 1,176 the
sixth run reported, not 993 more. Compared by test name, all 993 appear in the sixth run and none is unique to
the seventh. Distinct: 9,539 across 433 files. A naive sum reports 10,532 across 485, which is
that same 993 counted twice. Raising a ceiling weakens no assertion — it stops timing a transform —
and a genuinely hung test still fails.
The layer we lean on hardest reports failures badly. Integration is where tenancy and privacy are
actually proven, and a great many of its call sites destructure { data } and throw the
error away. When one fails you get the assertion’s disappointment
(expected null to be false) instead of the database’s explanation — the difference between a
minute and an hour. Which of them matter divides cleanly:
109 RPC calls and 94 writes lose a real message, because a raised function and a refused
INSERT both put the diagnosis in error and null in data.
The remaining 424 are reads, where an RLS denial returns empty data and no error at all, so there
is nothing to discard. That leaves 203 sites worth changing rather than the six hundred a blunt count suggests, and
they concentrate: five files hold half the RPC ones. Do the writes first — a refused
DELETE returns [], which is indistinguishable from the row not existing or the
filter being wrong, so a test meaning “this was refused” can pass for the right reason and for
three wrong ones. This is a diagnostics problem, not a correctness one
— every one of these tests still fails when it should.
What the other two layers do. Integration is the most valuable and, as of
this evening, the one demonstrating why it needs a pipeline. It exercises the real Supabase stack under
Docker, which is what lets it assert on things a bare database cannot reach — eight of its tests drive
the Storage HTTP API directly, including the one that matters most, proving an attacker cannot reach another
resident’s attachment by inserting a self-authored message pointing at their path. It runs
1,286 of 1,286, and it went briefly to 1,280 this evening in a way worth keeping on the record. A seed
change made an amenity open rather than bookable —
correctly, the customer had asked for that distinction — while six booking tests still named that
amenity by id. Nothing was wrong with the product and nothing was wrong with the tests; a constant went stale,
and it was resolved on the seed side because a ten-desk co-working room is something a resident should be able
to book, one open amenity being enough to show the contrast.
What that incident actually exposed. The commit went green through all three CI jobs and broke
six integration tests, and the only reason it surfaced within the hour is that somebody ran the suite by hand.
The gap is narrower than “integration is not in CI”, and worth stating precisely:
seed.sql is an input to the integration suite, and the only job that touches it checks
that it loads — not that anything still passes against it. A seed edit is exactly the class of
change that clears one and breaks the other. Until an integration job exists, every seed change is unverified
against the layer that proves tenancy and privacy. End-to-end is now the layer with the
most automation behind it: CI runs all 47 on a dedicated runner against a real Supabase started from
these migrations, and it passes there. Locally it is 47 of 47 on a quiet machine and less on a busy one.
Getting a trustworthy number out of it took a day, and the way I got it wrong is the more useful half.
Most of the variation was environment. One run of 34 was an admin build carrying a complete set of manifests
and noBUILD_ID: next start then serves a half-built app, every
authenticated admin test bounces to /login, and the failure count is a signature rather than a
symptom. Another was the dev servers being killed underneath the run. Underneath both sat memory rather than
CPU, described below.
But not all of it, and I said otherwise. After each environmental cause was removed, two failures
persisted in the concurrent-load project, and this page called them a 60-second navigation timeout —
environment again. They were a real defect. Every protected page resolved the caller with
authError || !user and redirected to /login on either, so a lookup that merely
failed was indistinguishable from a session that was genuinely gone. supabase-js sets no
timeout on its fetch, so GoTrue under pressure answers slowly or drops the connection, and the page signed out
a resident who was signed in. Four simultaneous sign-ins reproduce it. The fix asks twice before believing a
failure — a revoked session fails identically both times and still redirects, a blip resolves on the
second ask — and it had to be applied to all 34 pages carrying the pattern, because the bounce
lands on whichever page the resident opens next.
So the honest lesson is sharper than the one I first drew. An end-to-end failure is a claim about the
environment or the code, nothing in the suite distinguishes them, and a genuinely bad environment is
the most effective way to dismiss a real bug — because every environmental explanation you offer is
also true.
And the cause underneath both of those turned out to be memory. Not CPU, which is the metric everyone
reaches for. On this machine, macOS swapping stalls the Docker VM the whole stack runs inside, and the
symptoms arrive in an order worth knowing because the useful ones come first and the alarming ones come last.
Measured over two days on one machine, so treat it as a shape rather than a law:
Warm route times. A second-pass render over 1.5s, against 300ms–1.3s healthy. This moves
first — /home was measured at 8.6s while everything else still looked fine.
PostgREST logging Thread killed by timeout manager. The best signal of
the four, because it is server-side and needs no test running to produce it.
GoTrue losing its database connection, and /auth/v1/token returning 504. By here it
is visible to whoever is using the app.
Playwright reporting content that never appeared. Last, and the least informative: it reads as a
product fault, which is exactly how it costs an hour.
The trap is that the first and last both look like the product being slow or broken, which is why the passive
one is the one to name in a runbook. And the reason this is not simply “the machine was busy”:
load average did not distinguish the two states. At the same load, with swap under control, those
same routes measured 304ms to 1.3s; with swap at 98% they measured 3 to 9 seconds and auth intermittently
504’d. A CPU-only health check passes in both.
What the tests caught, and what they missed. Getting here meant fixing the product as well as the
tests: hidden posts had stopped being hidden after a later migration recreated the read policy without the
moderation clause (see 5.10); a lease-renewal audit trigger ran as the caller and so blocked every property
manager from creating a renewal; residents could not withdraw from an event once it was cancelled; the
moves and deliveries notification preferences were never consulted; and the
weekly-digest function rejected the scheduled job meant to call it. One integration file had never executed
at all — a block comment containing A*/V* closed itself early and the parser dropped the file,
hiding 31 tests. Against that, the defects in 5.11, 5.12 and 5.13 were found by interrogating a running
database, not by any suite: 9,539 unit tests had nothing to say about who may call a function, because
they never call one. The notification bug in 5.6 is the sharpest example — both triggers worked, wrote
correct rows, and would satisfy any test asserting that a notification appears. What was wrong is that one
never failed to appear, and nothing was asserting an absence. That is the honest limit of this pyramid, and
the reason the integration layer is the one worth growing.
The anon grant is still open by default on most SECURITY DEFINER functions. Of the
254SECURITY DEFINER functions, 146 are still executable by the unauthenticated
anon role at the grant layer, 92 of them directly callable rather than trigger bodies.
Every one that is safe today is safe because its own body checks the caller — one layer, not two, and 5.12
is what happens when that layer has a typo in it. The four functions proven exploitable are closed, and the
points RPCs before them, but each was closed individually after being found. The right end state is the
inverse: revoke from PUBLIC and anon by default, then grant back the handful that
genuinely serve signed-out callers. That is a mechanical change to roughly 90 functions plus a test that
fails when a new migration reintroduces the grant, and it would retire this entire class rather than the
four instances of it.
Residents are not told that staff can read their messages. A property manager can read every 1:1
thread in their property. That is deliberate — the schema labels the policy "moderation" — and there are good
reasons to want it. But nothing in the resident-facing app says so, and a neighbour-to-neighbour chat is
exactly where people assume privacy. This is a disclosure decision that should be made on purpose rather than
inherited from a comment in a migration, and in some jurisdictions it is not only a product choice.
Nothing leaves the building by email. The weekly digest computes correctly and raises an in-app
notification, but its rows sit at email_status = 'pending' waiting for an SMTP worker that does
not exist in the repo. Combined with the unprovisioned push channel above, that means every notification
today requires the resident to open the app — which caps what a digest, or an urgent announcement, can
actually achieve.
Two local stacks, and only one of them is the platform. Development runs real Supabase under
Docker, which is what the integration suite asserts against. A no-Docker fallback
(dev_workflow/architecture/local-supabase.sh) exists for machines that cannot run Docker: native
PostgreSQL, PostgREST, and a Node process standing in for /auth/v1. It is enough for RLS work and
nothing more — no Storage, no Realtime — and running it beside the real stack shadows port 54321
and silently replaces the demo database, as described in section 06. Treat it as a last resort, not an
equivalent. The schema checks are independent of both: verify-migrations.sh and
verify-figures.sh each build their own throwaway PostgreSQL and replay all 263 migrations into it.
What I would do next, in order
Commit a lockfile. CI resolves dependencies fresh on every run today, so the build can go red without
anyone changing the repository, and a green run does not describe a reproducible tree. It also unblocks
npm ci, npm caching and setup-node@v5, all of which the workflow currently works
around in comments.
In parallel, and not blocked by any of the CI work: flip the anon grant to deny-by-default
across the 92 directly-callable SECURITY DEFINER functions, then grant back the few
that genuinely serve signed-out callers, and add an integration test that fails when a new migration
reintroduces the grant. This retires the class in 5.12 instead of the four instances of it, and it is the
cheapest item on this list.
Get the 37 integration suites into CI. They prove tenancy and privacy, they return the same numbers on
every run, and they are now the only layer no automation touches. The browser job already starts a
real Supabase on its runner, so this is pointing a step at npm run test:integration rather than
solving the problem that used to block it.
Audit the remaining assertions for the shape the Perks test had. It was adopted rather than deleted, and
adopting it found the more interesting defect: it searched the whole document for “404”, and
Next inlines its flight payload, so every page in the app contains both “404” and “Page
Not Found” regardless of what rendered. The assertion could not have failed even had it run. Whole-body
text searches are worth grepping for wherever else they appear.
Complete the EAS configuration so the mobile app has a reproducible build.