2022 to present
Cynad
Multi-tenant fund administration platform
Role: Lead engineer. Owned both Next.js apps end to end; co-built the Rails API with the founder.
- cynad.com ↗product site
- demo.cynad.com ↗book a demo
- tigerhilladvisors.com ↗a live tenant of the investor portal
- lines of first-party code
- ~206klines of first-party code
- fund managers live
- 4fund managers live
- RSpec examples
- ~1,100RSpec examples
- commits
- ~4,680commits
Context
What this is for
Emerging fund managers lose allocations on operations, not performance. The documents exist, but they sit in someone's inbox; the performance history exists, but it has to be reconciled before anyone can see it. To an allocator screening dozens of funds a year, slow is indistinguishable from evasive. Cynad is the platform that removes that latency: the data room is the system of record, not a folder bolted onto one.
Problem
The thing that was actually hard to get right
Every fund manager wants their investors to see their brand, on their domain, with their fund terms, and none of them will accept a shared-looking portal. The naive answer is a fork per client, which is what existed when I started: three forked repos drifting apart, each needing the same security fix applied three times. The real problem was to serve N managers from one codebase without ever leaking one tenant's data, branding or session into another's.
What I built
The surface area
- A white-label investor portal where investors complete a seven-section KYC profile, subscribe to a fund class as an individual or a corporate entity, invite co-investors, request redemptions, and download their own statements.
- A manager admin console covering investor onboarding, bulk CSV import of prices, transactions and investors, position consolidation, statement generation, a TipTap letter and news editor, and a CRM with outreach email and click tracking.
- A Rails 8 JSON:API serving three audiences (investors, fund managers, and fund administrators acting on a manager's behalf) from one model of the fund structure: marketed fund, fund class, series, historical price.
- A public, crawlable fund catalogue with permissioned materials, so a prospect can diligence a fund at 11pm on a Sunday without emailing anyone.
- Eleven print-exact PDF documents: subscription forms, redemption notes, monthly and annual statements, consolidation notes.
Interactive
Pick a host. Watch this page become that fund.
One Next.js codebase serves four fund managers. The host decides the tenant, the tenant decides the theme, and nothing is rebuilt to add one. The fourth host is not in the map on purpose.
HOST_TO_ORG
In production this map holds thirteen entries across development, staging and four production domains.
Hong Kong long/short equity
session cookie: TIGER_HILL_token
GET /client_variables/public_config
{
"organization": "Tiger Hill Advisors",
"cookie_prefix": "TIGER_HILL",
"accent_color": "#c8a24a",
"accent_ink": "#1a1403",
"accent_text": "#c8a24a",
"font_family": "Space Grotesk",
"hero_tagline": "Hong Kong long/short equity"
}Those values are now on this document’s root. The accent and display face you are reading came from the object above. accent_text is a separate value because no single hue clears AA contrast on both a near-black and a near-white surface, so the tenant carries one per theme.
The hard parts
Decisions worth defending
Each of these names its trade-off, because a decision without one is a preference.
Fail closed, unless closing fails worse
Tenancy is resolved from the Host header against a map of thirteen hosts, and each tenant gets its own cookie namespace (TIGER_HILL_token, JPOOF_token) so one browser can hold separate sessions on separate tenant domains without collision. The investor portal used to fall back to a default tenant for any unrecognised host, which meant a misconfigured DNS alias or a forgotten review app would silently impersonate a real fund and receive its cookies. It now returns 404 for any host not in the map, in production and in development. The admin app, in the same product, deliberately does not do this: its default host is the public marketing site, so 404ing an unlisted host would take cynad.com down over a stray health check with an odd Host header.
The trade-off
Two apps in one product with opposite defaults. That is only defensible because the reason is written down next to each one, and the blast radius of being wrong is different: a leaked tenant session versus a marketing page that stops resolving.
Returns that do not lie when money moves mid-period
If you compute a period return from opening and closing portfolio value, an investor who subscribed in March reads as though the fund made them money it did not make. Returns are computed from NAV per share instead, with opening and closing shareholding reconstructed from the transaction ledger and in-period additions and subtractions reported separately. Prices are publish-once: a published price must post-date the series' latest and can never be edited, and a transaction may not pre-date its position's latest. Those invariants are enforced in the model and re-checked cross-row in the bulk CSV path, because the valuation code depends on them holding rather than re-deriving them.
The trade-off
Immutability makes corrections harder: fixing a bad price means a correction transaction, not an UPDATE. That is the right trade for records an auditor will read, and the wrong one for almost anything else.
A rate limiter that was bypassable by spelling
Rack::Attack throttles were keyed on the request path. POST /users/sign_in.json and //users/sign_in are the same Rails action and a different string, so either spelling disabled every throttle in the file, not just the sign-in one. The fix resolves the request through the application's own router and matches on controller#action, memoised in the Rack env so the lookup costs 10 to 28 microseconds per request. Two-factor sign-in has a related shape: it answers "second factor required" before authenticating, so a correct password against a 2FA account cannot be used as an oracle by watching the lockout counter.
The trade-off
Matching on resolved routes couples the throttle config to route names, so renaming a controller action silently widens the rule. The spec exercises the real module rather than a test double, so that shows up as a failure.
Ownership
Who wrote what
Commit counts from the repository history, both of my git identities summed. I would rather give you this than a claim you have to take on trust.
| Surface | Commits | My share |
|---|---|---|
| Investor portal | 1,118 | 96% of commits, owned end to end |
| Manager admin console | 833 | 94% of commits, owned end to end |
| Rails API | 1,180 | 40% of commits, co-built with the founder |
Stack
What it runs on
Frontend
- Next.js 16 (App Router)
- React 19
- Redux Toolkit
- Formik
- MUI 7
- Sass
- TipTap
- Motion
Backend
- Rails 8.1
- Ruby 4.0
- PostgreSQL
- Devise + JWT
- devise-two-factor (TOTP)
- Pundit
- jsonapi-serializer
- Rack::Attack
- Active Record Encryption
Infrastructure
- Heroku
- AWS S3 (Active Storage)
- Resend
- Headless Chrome (ferrum) for PDF
- GitHub Actions
Testing & quality
- RSpec (~1,100 examples)
- Playwright (5 tiers, 68 specs)
- Jest + React Testing Library
- axe-core accessibility checks
- Brakeman
- bundler-audit
- RuboCop
Where it is now
Current status
Live and in active development. Four fund managers on their own domains, plus a public fund catalogue. The three per-client forked repositories it replaced have been retired.