# Where the boundaries sit in Rental OS

> How Rental OS connects five portals to shared records, authenticated reactive queries and asynchronous work, how every public Convex function was swept for authorization before the source went public, and which integrations remain unfinished.

Published: 2026-09-23  
Updated: 2026-09-23  
Work period: July to August 2026  
Product: [Rental OS](https://adityadalmia.com/projects/rental-os/)  
Canonical HTML: https://adityadalmia.com/blog/rental-os-system-boundaries/

A tenant looking at an appointment and an operations employee rescheduling it need different interfaces to the same record. If each interface keeps its own copy of the appointment, the product has a reconciliation problem before it has solved the scheduling problem.

[Rental OS](https://adityadalmia.com/projects/rental-os/) puts five portals over one application model. Next.js serves pages, server-rendered entry points and browser components. Convex holds the domain records and runs queries, mutations, actions and scheduled jobs. WorkOS AuthKit establishes who is signed in. The interesting decisions are about what crosses those boundaries: an access token, a reference to an existing record, a requested state transition, or the result of an external call.

The source is [public under the MIT licence](https://github.com/AdityaVikramDalmia/rental-platform-os), so this article names real modules and functions. My work spans product design and application engineering: the participants' workflows, the domain and permission rules, and the integration of interface, backend and deployment. The recorded work period is July to August 2026; the code described here is `main` as of September 23, including the September login, authorization and deal-room fixes. The imported Git history does not date individual subsystems. Preparation, verification and the September fixes used AI assistance, and Next.js, React, Convex, WorkOS and the interface libraries supply a great deal of upstream machinery.

![Explanatory architecture diagram showing five Rental OS portals, WorkOS authentication, authenticated Convex queries and mutations, shared domain records, and asynchronous actions.](https://adityadalmia.com/_astro/system-boundaries.BiImCJnj.svg)

*Explanatory architecture diagram; it is not a product screenshot or a network inventory.*

## One application, several views

The portal split follows how people work. Guards need a focused phone interface. Administrators need wide queues and controls. Tenants and owners need only the parts of a rental that concern them. Next.js route groups, `(guard)`, `(ops)`, `(admin)`, `(tenant)` and `(owner)`, organise those experiences, and each group's server layout checks admission before rendering.

Underneath, `convex/schema.ts` defines one model with typed document references: `users`, `roles`, `user_role_assignments`, `owners`, `leads`, `listings`, `tenant_inquiries`, `visits`, `negotiations`, `chat_channels`, `rental_transactions`, `closures`, `payouts` and more. A visit refers to its tenant inquiry through `tenant_inquiry_id`. The inquiry does not maintain its own copy of the visit's facts. That gives the backend a relationship to follow when one workflow changes another.

Cancellation is a concrete example. A trigger registered on the `visits` table in `convex/functions.ts` watches for a visit moving to `CANCELLED` or `NO_SHOW`. If the linked inquiry is still `VISIT_SCHEDULED`, the trigger moves it back to `GUARD_ACCEPTED` and clears the matching visit reference, ready for another attempt. The correction sits beside the write, so it applies whichever portal made the change.

Shared records make these relationships easy to express, and they concentrate responsibility. A page can look correct while an inconsistent transition sits behind it. The backend has to validate the operation and keep the links consistent; reactive rendering cannot repair a bad write.

## Authentication reaches the query boundary

In the browser, `ConvexClientProvider` wraps the app in Convex's `ConvexProviderWithAuth` with a small adapter, `useAuthFromAuthKit`, that fetches and refreshes the AuthKit access token. On the backend, `convex/auth.config.ts` trusts WorkOS as a custom JWT issuer: RS256 tokens verified against WorkOS's published signing keys. Every Convex function can then ask `ctx.auth.getUserIdentity()`.

The identity is only a subject string. `getAuthenticatedUser` in `convex/auth.helpers.ts` resolves it to an application user through the `users.by_workos_user_id` index. That local record is the bridge from “WorkOS says this is user X” to “what may user X do here?” The helpers then layer checks:

```ts
// convex/auth.helpers.ts — requirePermission (abridged)
const user = await requireAuth(ctx);              // signed in, not banned
if (!hasPersona(user, "ADMIN") && !hasPersona(user, "OPS")) throw …;
if (user.status !== "ACTIVE") throw new Error("Account not active");
// union the permissions of every non-deleted role assigned to this user
if (!permissions.has(permission)) throw new Error(`Missing permission: ${permission}`);
```

`lib/constants.ts` defines 115 permission strings, from `societies.view` to `system.configure`. Roles are named bundles of them, stored as data in `roles` and assigned through `user_role_assignments`.

The September login work carried the requested portal through the hosted sign-in round trip. The callback accepts only the five known portal values, and `users.resolvePostAuthDestination` checks the requested portal against the account's memberships. An operations account asking for the admin portal is let into the permission-filtered backoffice; a tenant asking for it is sent back with `role_mismatch`.

There is a deliberate limit here. The core helpers check **membership**, the `user_types` array, not the `active_persona` field that records which portal someone is currently using. The active persona controls the experience; backend checks decide each operation. Treating a persona switch as a security context switch would promise a stronger boundary than the code provides. Elsewhere the active persona labels a message or narrows a list behind a permission check that already ran. The one place it widened access, deal-room chat, was a bug the sweep below caught.

## Authorization, swept before the source went public

Publishing the source changes the threat model in one specific way: everyone can read every function's argument list. Before the repository went public, every client-callable entry point was reviewed: each exported `query`, `mutation` and `action` in `convex/`, plus each HTTP route. The record is [`docs/security/authz-sweep-2026-09-23.md`](https://github.com/AdityaVikramDalmia/rental-platform-os/blob/main/docs/security/authz-sweep-2026-09-23.md) in the repository.

The method was deliberately mechanical. The function list came from the source itself, so nothing depended on memory of which endpoints mattered. For each function body, and any local helper it calls, three questions:

1. Is the caller authenticated?
2. Is the gate a role permission (`requirePermission` or `requireAnyPermission`), a persona check for a self-only record, or an ownership or participant check?
3. Does the check run before any data access?

Permission-gated functions were also checked for fit: reads need a VIEW permission, writes a MANAGE or EDIT permission. Public functions were checked for exactly which fields they return and whether they are rate-limited.

| Result | Functions |
| --- | --- |
| Public by design: public-safe fields, rate-limited writes | 10 |
| Authenticated and correctly scoped | 490 (17 with follow-up notes) |
| Gaps fixed, function still public but now gated or trimmed | 26 |
| Gaps fixed in the deal room, in a follow-up change | 7 |
| Gaps fixed by making the function internal | 3 |

That is 536 exported functions before, 533 after. The gaps fell into a handful of recognisable classes:

- **Entry points that should never have been client-callable.** A data-migration action with `run` and `rollback` had no login check and returned migration plans containing user ids; nothing in the web app called it. It became an `internalAction`. The support-inquiry `submit` mutation let callers bypass the contact-form route and its shared-secret check; it became internal, and the HTTP route calls it through `internal`.
- **Trusting an identifier from the client.** `actions/workos.resetGuardPassword` reset the password of whichever WorkOS account the client named. It now looks up the WorkOS id of the guard being reset and refuses a mismatch.
- **Minting privilege.** `roles.create` accepted `is_system_role` from the client, which produced a role that cannot be renamed or deleted. The argument is gone; system roles come only from the seed. Creating an operations account needed only `guards.create`; it now also needs `roles.manage`, since the account receives a role.
- **Persona gates where a permission was meant.** The field-worker rollout controls checked only for an admin membership; they now require `system.configure`.
- **Reads that returned too much.** A tenant's view of a rental transaction included the owner's contact record, the closure's commission and internal notes. A guard's bounty list included the tenant's identity. An owner's relationship-manager view included that employee's internal performance data. A public referral-code lookup returned the referrer's name and internal ids. Each now returns a trimmed shape built for its screen; the staff views are unchanged.
- **Check ordering.** `opsManagement.acknowledgeWarning` read the warning before checking login, so an anonymous caller could learn whether an id existed. Login now comes first.
- **Access keyed on the active persona.** Deal-room chat decided room visibility from `active_persona`. [Inside the deal room](https://adityadalmia.com/blog/rental-os-inside-the-deal-room/) covers the fix, which derives the caller's role from the inquiry and listing records.

![Admin roles page listing two system roles: Ops Agent with 43 permissions and Super Admin with 115.](https://adityadalmia.com/_astro/admin-roles-system.D-tgV2mJ_1okyDV.webp)

*Hosted demo, synthetic seed data, captured 2026-09-23. Both roles come from the seed and carry the System badge; Super Admin holds all 115 permissions defined in lib/constants.ts. After the sweep, roles.create no longer accepts is\_system\_role, so a client cannot create another undeletable role.*

Every fix has a regression test, and the tests had to fail on the old code to count. `convex/authzHardening.test.ts` and `convex/authzHardening.workos.test.ts` add 25 tests, with the WorkOS client mocked so nothing touches the network. I ran them against the pre-sweep revision: 21 fail and 4 pass. The four that pass are the positive controls, such as “the matching guard's password can still be reset”, which prove that each fix narrows access without breaking the legitimate path. The deal-room change adds 12 tests in `convex/dealRoomAuthz.test.ts`; 7 fail against the code before that fix. On `main`, the whole suite runs 382 passing tests with one skipped.

The sweep also lists open items that need a product decision rather than a patch: whether public listing pages should show exact flat numbers, how the owner “earnings” view should be modelled, some rate limits and webhook secret checks that should be tightened, and a set of separation-of-duties notes where one permission holder can act on their own record. They are written down in the same file so they stay visible.

What the sweep is not: a penetration test, a formal verification, or proof that no gap remains. It is a complete, source-derived reading of the public surface with a failing-first test for every fix. That is a concrete floor for a codebase that anyone can now read.

## Reactive reads reduce coordination in the browser

Chat shows the query path. `ChatView` subscribes to channel details, unread counts and recent messages with Convex `useQuery`; `MessageList` uses `usePaginatedQuery`; `ChatInput` calls the `chatMessages.send` mutation. Convex keeps those subscriptions current as the underlying documents change, so there is no separate polling or message-distribution layer to maintain for these screens.

![Tenant messages page listing deal-room conversations with status badges, unread counts and a live-updates indicator.](https://adityadalmia.com/_astro/tenant-message-list.BMmTU6Ob_Z1vkUtf.webp)

*Hosted demo, synthetic seed data, captured 2026-09-23. The list comes from a paginated Convex query, chatChannels.listMyChannels. The “Live updates connected” label reflects the browser's online state (navigator.onLine), not the Convex connection itself. The \[seed-neg] markers are the seed data's own labels.*

Reactivity does not decide what a query may expose. A subscription is a continuing read surface, so it needs the same field-level discipline as a one-off request. Several of the over-broad reads in the sweep were reactive queries, and faster propagation would only have made them visible faster.

I have no load-test result, so I make no claims about concurrent users or delivery latency. The evidence is the programming model and the deployed portal journeys.

## Durable writes leave operational evidence

Every backend module that writes to the database, apart from one demo cleanup mutation, imports `mutation` and `internalMutation` from `convex/functions.ts` rather than from Convex's generated server. Those builders wrap the database handle with `convex-helpers` triggers, and `registerAuditTrigger` attaches an audit trigger to each of the 76 tables listed in `AUDITED_TABLES`. Every insert, update or delete on those tables writes an `audit_logs` row with the actor, an action such as `LEADS_UPDATE`, the entity reference, and a field-level diff. Keeping this at the database-wrapper boundary means no form component has to remember to write an audit entry.

\[Figure: Admin audit log showing rows with actor System, actions such as NOTIFICATIONS Created and OWNER\_RM\_ASSIGNMENTS Updated, and entity references.]

*Hosted demo, synthetic seed data, captured 2026-09-23. Rows attributed to System come from writes with no authenticated identity, such as seeding and scheduled jobs; resolveActor records SYSTEM in that case. The page heading says “full history of all platform actions”; precisely, it covers writes to the 76 audited tables made through the wrapped mutation builders.*

The same layer keeps three `@convex-dev/aggregate` components current: lead counts, visit counts and payout totals. A **payout** is a recorded payment owed to a worker, such as a hypothetical $10 visit reward. Summing payout records does not verify that any bank transfer happened. Daily analytics snapshots and operational checks run separately as scheduled jobs from `convex/crons.ts`.

These records answer different questions. An aggregate answers a summary question cheaply. An audit row explains one change. Neither amounts to event sourcing or a tamper-proof history: the audit covers a defined table list, and a demo cleanup mutation deliberately uses the raw builder.

## External work has a separate lifecycle

Calls to outside services run in Convex actions, and mutations record the durable state around them. Chat batching illustrates the split. `chatBatching.queueMessage` records a message and schedules `processBatch`, which schedules `processWithAI` as an action. `deliverBatch` and `failBatch` record the result, and a cron runs `recoverStaleBatches` for batches stuck mid-flight.

The notification subsystem records events and per-channel state first. It applies recipient preferences and deduplication, retries with exponential delay, and moves exhausted work to a dead-letter state. Recovery of stale processing is a scheduled job rather than something a page has to remember to trigger.

That machinery is real, but whether an integration is complete depends on the action at the end of it. The push, WhatsApp, SMS and email adapters in `convex/actions/notifications.ts` check configuration and record a synthetic delivery; they call no provider. The identity-verification actions in `actions/kyc.ts` and the e-signature actions in `actions/esign.ts` return canned results after a short delay. No code creates a payment order, and the payment webhook verifies signatures but only logs the event. A successful-looking record from any of these paths is not evidence that a person was notified, verified, signed or paid.

Two actions do call a provider when configured: `actions/transcription.transcribeAudio` sends a stored recording to OpenAI, and `actions/dealTermExtraction.extractTerms` requests structured output and maps terms back to message ids. The sweep moved the field-worker check for transcription in front of the paid call. I did not invoke either service for this article, so their presence supports an implementation claim, not an accuracy claim.

## Deployment changes more than a URL

The supported deployment is one stateless Next.js container, built as a standalone server running as a non-root user, talking to external Convex and WorkOS services. `/healthz` reports whether the web process is up. Authenticated portal journeys exercise far more of the system than that probe does.

The September login release kept the backend and web revisions explicit. The backend deployed first, because the new frontend passes an optional `intended_persona` argument that only the new backend's argument validator accepts; older callers keep working because the argument is optional. The web image followed. No schema migration or demo reseed was needed.

Production verification caught what entry-page checks missed. Sign-out cleared the session cookies, then redirected to `/`, which by then itself redirected to `/homepage`. The client expected a Server Action redirect response, received an ordinary HTML document, and showed its error boundary. The fix sends sign-out straight to `/homepage`, and `scripts/check-signout-redirect.mjs` reproduces the original failure against a production build on loopback. The public summary is in the repository under `docs/releases/login-split-2026-09-22/`.

That record covers the five fixture sign-ins and the tenant-to-admin refusal. It does not prove every data-changing journey or production readiness. The hosted demo is shared synthetic state, not a per-visitor sandbox, which is why access is arranged on request.

## Reading the source

The repository's README has a zero-signup mode: `LOCAL_AUTH=true npm run dev` starts a local Convex backend with a locally signed JWT issuer, and `LOCAL_AUTH=true npm run seed` loads the synthetic fixtures, with no WorkOS or Convex account required. `SECURITY.md` explains how to report a vulnerability privately.

If you want the boundaries in the order this article presents them, read `convex/auth.helpers.ts`, then `convex/functions.ts`, then `lib/negotiation.ts` with `convex/negotiationProposals.ts`, then the sweep record. The simulated adapters are labelled as such in the README, and the prototype status is not a figure of speech: identity checks, e-signatures, notifications and payments are simulated or record-only.

[Read the source on GitHub](https://github.com/AdityaVikramDalmia/rental-platform-os), or [request demo access](https://adityadalmia.com/projects/rental-os/#demo-access) for a guided look at a particular portal.
