Contributions from everyone are welcome. This project is community-driven by design. The predecessor app suffered from being a one-person effort, and Pluralscape is built as the opposite.
- Fork the repository
- Create a feature branch (
git checkout -b feat/your-feature) - Write tests first, then implement (see Development Methodology below)
- Ensure all tests pass and coverage thresholds are met
- Open a pull request against
main
- Use the PR template — it includes a review checklist covering privacy, offline behavior, accessibility, and data safety
- Keep PRs focused — one feature or fix per PR
- Include tests for new functionality (written before the implementation — see TDD)
- Update documentation if behavior changes
- List any deferred work in the "Deferred Items" section of the PR template
Every PR must pass these checks locally before it is opened; CI enforces them as well:
pnpm format— Prettier formattingpnpm lint— ESLint with zero warnings (--max-warnings 0)pnpm typecheck— TypeScript strict type-checkingpnpm types:check-sot— Types-as-SoT parity gate (types + Drizzle + Zod + OpenAPI-Wire parity; CI-enforced, any drift fails)pnpm test— unit + integration suitespnpm openapi:check— REST spec reconciler; the checked-indocs/openapi.yamlmust match generated outputpnpm trpc:parity— REST route / tRPC procedure parity (see Adding API Endpoints)
The /verify command runs the full suite (format, lint, typecheck, unit, integration, e2e) in one shot.
Pluralscape is pre-production. Remove deprecated code outright. No backwards-compatible aliases, re-exports, or @deprecated shims. Delete old symbols in the same PR that introduces the replacement.
We use Conventional Commits:
type(scope): description
Types: feat, fix, refactor, docs, test, chore, style, perf, ci, build
- Imperative mood, 72 characters max, no trailing period
- Subject must start lowercase — commitlint extends
@commitlint/config-conventional, which enforces sentence/start-case rejection.feat: Add foofails the hook;feat: add foopasses. - Branch naming:
type/short-desc(50 chars max)
Major technical decisions are documented as ADRs in docs/adr/. If your contribution involves a significant architectural choice, please write an ADR using the template at docs/adr/000-template.md.
The server must never perform master-key operations or handle raw user-secret material. All envelope encryption, bucket key derivation, and recovery-package assembly happen client-side. See ADR 013 — API Auth & Encryption and the master-key migration in PR #439 for the authoritative boundary. PRs that route master-key material through the server will be rejected.
Work items are tracked as beans (a CLI issue tracker) with files committed under .beans/ alongside the code they describe. Full conventions live in docs/work-tracking.md.
- Before committing, mark the bean as completed and add a
## Summary of Changessection describing what shipped. - Include the bean file in the same commit as the code change so history stays self-contained.
- Use the existing prefixes (
ps-,api-,mobile-,db-,crypto-,sync-,types-,client-,infra-). Epics are containers — break them intofeature/task/bugchildren.
This project follows Test-Driven Development (TDD). All new code should be written test-first.
- Red — Write a test that describes the behavior you want. Run it — it should fail.
- Green — Write the simplest code that makes the test pass.
- Refactor — Clean up duplication, improve naming, restructure — while keeping tests green.
- New features: Start by writing tests for the public API or user-facing behavior. Then build the implementation to satisfy them.
- Bug fixes: First write a test that reproduces the bug. Confirm it fails. Then fix the code and confirm the test passes.
- Refactors: Ensure existing tests cover the code you're changing. If they don't, add tests first, then refactor.
Pluralscape handles sensitive personal data (identity, fronting, journaling) with privacy and encryption guarantees. TDD helps the project:
- Catch regressions in privacy and encryption logic before they ship
- Build confidence in offline-first sync behavior through repeatable tests
- Maintain coverage naturally, without chasing metrics after the fact
- Design cleaner APIs by thinking about usage before implementation
That's okay. It's a practice, not a gatekeeping requirement. If you're new to TDD:
- Start small: write one test before one function
- It's fine to spike (prototype without tests) to explore an approach, then delete the spike and rebuild test-first
- Ask for help in Discussions if you're stuck
PRs without tests for new functionality will be asked to add them. PRs that follow the TDD cycle (test commits before implementation commits) are appreciated but not strictly required. What matters is that tests exist and cover the behavior.
- Code quality matters, not the tools used to write it. AI-assisted contributions are welcome
- Write tests for new functionality
- Follow existing patterns and conventions in the codebase
- Ensure accessibility (WCAG compliance) for any UI changes
- Privacy defaults to maximum restriction (fail-closed)
- Use community terminology, not clinical language. Say "system" not "patient", "member" not "personality", "fronting" not "presenting"
Strict typing is enforced. The following are not permitted:
- No
as any— find the correct type or fix the type upstream - No
as unknown as T— this is a double-cast escape hatch; restructure the code instead - No
@ts-ignore— if the compiler is wrong, use@ts-expect-errorwith a comment explaining why - No
@ts-expect-errorwithout a justification comment — explain the specific compiler limitation - No
eslint-disablecomments — fix the violation or raise a discussion about the rule - No non-null assertions (
!) — use narrowing, early returns, or explicit checks instead - No
var— useconstby default,letonly when reassignment is necessary
as never is allowed only in exhaustive switch/match default cases to enforce compile-time exhaustiveness:
// correct usage of `as never`
switch (status) {
case "fronting": ...
case "co-fronting": ...
default: {
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}- Explicit return types on exported functions — inferred types are fine for internal/private functions
- No floating promises — all promises must be
awaited, returned, or explicitly voided withvoid - No swallowed errors — every
catchmust log, rethrow, or handle meaningfully. Empty catch blocks are not permitted - No
console.login production code — use structured logging.console.logis acceptable in scripts and tests only - Prefer early returns over deeply nested conditionals
- Exhaustive pattern matching — all
switchstatements on union types must handle every case (useas neverdefault) - No magic numbers/strings — extract to
*.constants.tsfiles. Each package/domain has its own (e.g.,crypto.constants.ts,middleware.constants.ts). The ESLint config disablesno-magic-numbersfor this glob. Every constant needs a JSDoc comment and should use numeric underscores for readability (86_400not86400). When adding a constant, extend the nearest existing*.constants.ts; only create a new file when entering a new domain.
All interactive UI elements must have accessibility props (accessibilityLabel, accessibilityRole) with minimum 44x44pt touch targets.
For hook conventions, offline-first patterns, the provider tree architecture, platform abstraction, and a walkthrough of adding new features end-to-end, see the Mobile Developer Guide.
packages/types is the single source of truth for every domain entity. Each
encrypted entity exposes a six-link canonical chain in its module under
packages/types/src/entities/:
<Entity>— full decrypted domain shape<Entity>EncryptedFields— keys-union of fields encrypted client-side<Entity>EncryptedInput = Pick<<Entity>, <Entity>EncryptedFields>— what callers encrypt<Entity>ServerMetadata— Drizzle row (plaintext columns +encryptedDatablob + anyServerInternal<…>fields)<Entity>Result = EncryptedWire<<Entity>ServerMetadata>— server JS-runtime response<Entity>Wire = Serialize<<Entity>Result>— JSON HTTP shape
When adding a new encrypted entity:
- Define all six types in
packages/types/src/entities/<entity>.ts - Add the entity to
packages/types/src/__sot-manifest__.tswith full slots - Add
<Entity>EncryptedInputSchematopackages/validation/src/<entity>.tsand re-export from the validation index - Add the Drizzle table; the parity tests will fail until
InferSelectModel<…>matches<Entity>ServerMetadata - Add the OpenAPI response schema; the G7 parity test (
scripts/openapi-wire-parity.type-test.ts) will fail untilcomponents["schemas"]["<Entity>Response"] ≡ <Entity>Wire - Add the client transform under
packages/data/src/transforms/<entity>.ts(functions only — no local domain/wire/encrypted-input aliases). Referencepackages/data/src/transforms/member.tsfor the canonical shape.
pnpm types:check-sot runs all four parity gates sequentially. CI blocks on
failure — drift at any layer fails the build. See ADR-023
for the full rationale, including the ServerInternal<T> and EncryptedBase64
brand conventions.
Every new API feature requires both a REST route and a tRPC procedure. The CI check (pnpm trpc:parity) enforces this.
Checklist:
- Add REST route in
apps/api/src/routes/ - Add tRPC procedure in the matching router under
apps/api/src/trpc/routers/ - Apply matching rate limit middleware on both (see table below)
- Apply matching auth level on both
- Import input validation from
@pluralscape/validation— do not define inline Zod schemas - Write unit tests for the procedure and route handler
- Write integration tests covering success, not-found, and unauthorized
- Run
pnpm trpc:parity— must pass before opening a PR
If the new endpoint is REST-only by design (SSE, infrastructure), add an entry to apps/api/scripts/trpc-parity.config.ts with a documented reason.
| Category | Limit | Use for |
|---|---|---|
readDefault |
60 req/min | Standard read operations |
readHeavy |
30 req/min | Expensive reads (reports, analytics) |
write |
60 req/min | Standard create/update/delete |
authHeavy |
5 req/min | Login, password reset, token exchange |
authLight |
20 req/min | Session refresh, token validation |
blobUpload |
20 req/min | File and photo uploads |
auditQuery |
10 req/min | Audit log and delivery log reads |
friendCodeRedeem |
10 req/min | Friend code redemption |
Rate limit categories are defined in @pluralscape/types. Use the exact same category on the REST route and the tRPC procedure — the parity check flags mismatches.
Services live in apps/api/src/services/ and follow a per-verb-file layout — no barrel. Callers import from the specific verb file (e.g., services/member/create.js), not from a package index or sibling re-export.
- Nest to match routes: a service nests under a parent iff the routes tree does. Mirror
apps/api/src/routes/. - Verb-split at 300 LOC: any service ≥300 LOC must be split into
services/<domain>/<verb>.tsfiles (create, queries, update, lifecycle, delete, etc.). Single-file peer modules are fine below that threshold. - Shared helpers: put shared types/helpers in
services/<domain>/internal.tsonly if consumed by ≥2 verb files. Single-consumer helpers stay local to the verb file. - No barrels: do not create
services/<domain>/index.tsor a siblingservices/<domain>.tsre-export. ThemoduleResolution: "Bundler"setting does not resolve./foo.js→./foo/index.tsby design, and barrels defeat the tree-shaking and static analysis benefits of explicit imports. - Hard cap: ESLint enforces
max-lines: 450onsrc/services/**/*.ts(skipping blanks/comments). If you hit it, split the file — do not raise the cap or add an override comment.
LOC ceilings are codified per area in tooling/eslint-config/loc-rules.js and enforced by pnpm lint (and pnpm lint:loc at the root). Counts are ESLint default — all lines including blanks and comments, matching wc -l. The rule is split, don't override: when you hit a cap, decompose the file. Never raise the cap or suppress the rule with a directive comment.
| Area glob | Cap |
|---|---|
apps/api/src/routes/**/*.{ts,tsx} |
200 |
apps/api/src/middleware/**/*.ts |
200 |
apps/api/src/jobs/**/*.ts |
200 |
apps/api/src/trpc/**/*.ts |
350 |
apps/api/src/services/**/*.ts |
450 |
apps/api/src/lib/**/*.ts |
500 |
apps/api/src/ws/**/*.ts |
500 |
apps/mobile/src/**/*.{ts,tsx} |
500 |
apps/mobile/app/**/*.{ts,tsx} |
400 |
packages/types/src/**/*.ts |
450 |
packages/sync/src/**/*.ts |
750 |
packages/queue/src/**/*.ts |
500 |
packages/import-core/src/**/*.ts |
500 |
Other packages/*/src/**/*.ts |
500 |
**/*.constants.ts |
300 |
Tests (**/*.test.ts, __tests__/**) |
750 |
Tier B caps (api/lib, api/ws, mobile/src, sync, queue, import-core) are lockstep. They currently sit at the value above with a small buffer over the largest in-tree file, and ratchet downward as the tree shrinks. Tier A caps (routes, middleware, trpc, services, types) are target standards. Both are hard errors in CI; treat the cap as a design signal, not a number to negotiate with.
Every database query against an RLS-protected table must go through the wrapper helpers in apps/api/src/lib/rls-context.ts:
withTenantRead(systemId, fn)— runsfnwithapp.current_system_idset tosystemIdon a read-only connection.withTenantTransaction(systemId, fn)— same, inside a transaction.
Bare db.execute(...) or db.transaction(...) outside the wrapper helpers (and the cross-account-*.ts helpers, which are the explicit exception for cross-account flows) is an ESLint error. The rule is configured in apps/api/eslint.config.js. The integration test rls-unset-context.integration.test.ts locks in the fail-silent behavior as a regression trap: un-contexted queries return [] rather than rows. A missed wrapper is a privacy bug that returns empty results rather than the wrong system's data, but a privacy bug nonetheless.
Every protected route group must type its Hono instance with AuthEnv:
import { Hono } from "hono";
import type { AuthEnv } from "../../lib/auth-context.js";
export const myRoute = new Hono<AuthEnv>();
myRoute.get("/", (c) => {
const auth = c.get("auth"); // typed as AuthContext, no assertion needed
// ...
});The parent mount must attach authMiddleware() via .use() before the typed
sub-app is .route()-mounted; the middleware is responsible for calling
c.set("auth", session) with the resolved AuthContext. Removing
authMiddleware() from a typed sub-app's mount chain surfaces as a compile
error at any downstream handler that reads auth, because c.set
guarantees the variable is present.
Public (unauthenticated) routes — auth/login, auth/register,
auth/salt, auth/password-reset, i18n, and the top-level v1 mount —
use bare new Hono() and must NOT call c.get("auth"). Add auth mid-path
only if the route is moved behind authentication.
Zero warnings are tolerated. All ESLint warnings are treated as errors in CI, git hooks, and local scripts (--max-warnings 0). If a rule is too noisy, discuss changing its severity. Do not leave warnings in the codebase.
Test coverage is enforced in CI. The thresholds below are minimums; aim higher where practical.
| Test Type | Coverage Target | Tool | What It Covers |
|---|---|---|---|
| Unit | 89% lines/functions/branches | Vitest | Pure functions, utilities, domain logic |
| Integration | 89% lines/functions/branches | Vitest | API routes, database queries, cross-module flows |
| E2E | Critical paths | Playwright | User-facing flows: auth, fronting, switching, sync |
- Unit + Integration (89% combined): Lines, functions, branches, and statements are all measured. Type-only files and barrel/index files are excluded from coverage. Measured across the combined unit + integration run.
- E2E tests: No line-coverage metric. All critical user journeys must have corresponding tests. Tracked via a test matrix in the test plan.
Coverage is checked in CI on every PR. PRs that drop coverage below thresholds will not merge.
- All interactive elements must have accessibility props (
accessibilityLabel,accessibilityRole, etc.) - Color must not be the only means of conveying information
- Touch targets must meet minimum size (44x44pt)
- Test with screen readers (VoiceOver on iOS, TalkBack on Android)
Pluralscape uses Crowdin for translation management. The project qualifies for the free open-source tier. The automation design is authoritative in ADR 036 — Crowdin automation; operational runbook lives in docs/i18n/crowdin-operations.md.
- English sources (
apps/mobile/locales/en/**/*.json) are the source of truth. - On merge to
main, a GitHub Action uploads changed source strings to Crowdin. - Translators work in Crowdin's web UI. Strings carry community-terminology notes (plural-community affirming language — never clinical).
- Every Monday at 06:00 UTC, a scheduled Action pulls approved translations and opens a PR titled
chore(i18n): weekly Crowdin translation sync. Maintainers review the diff and merge.
- Open a bean tracking the locale request.
- Add the locale tag to
SUPPORTED_LOCALESinpackages/i18n/src/i18n.constants.tsandBUNDLED_LOCALESinapps/mobile/locales/index.ts. - Update
crowdin.ymllanguages_mappingif the Crowdin locale tag differs from the Pluralscape tag. - Add an empty baseline directory
apps/mobile/locales/<locale>/with empty namespace JSON stubs; Crowdin will populate them. - Verify with
pnpm vitest run --project i18n.
Apps ship with bundled baseline translations for offline-first behavior. Translation fixes reach users via an API-proxied OTA overlay (GET /v1/i18n/:locale/:namespace) without requiring an app release. See docs/adr/035-i18n-ota-delivery.md for details.
Feature prioritization is community-driven. If you have a feature idea:
- Check existing Discussions and issues to avoid duplicates
- Open a thread in the Feature Requests discussion category
- Community upvotes help prioritize what gets built next
- Bugs: Open a GitHub issue with steps to reproduce
- Security vulnerabilities: See SECURITY.md — do not open a public issue
All contributors must follow our Code of Conduct. We have zero tolerance for bigotry, harassment, or gatekeeping.
English sources live in apps/mobile/locales/en/*.json. To add or modify a user-facing string:
- Add the key + English value to the appropriate file (
auth.json,common.json,fronting.json,members.json,settings.json). - Reference the key from the mobile UI via the i18n hook.
- Commit and open a PR as usual.
Translations are handled automatically:
- On merge to
main, thecrowdin-syncworkflow uploads the new source to Crowdin and triggers machine-translation (TM + MT with glossary enforcement) for all 12 target languages. - The next daily sync (06:00 UTC) opens a translation PR that auto-merges once CI passes.
If you're adding domain terminology (plurality, fronting, member roles, origins, etc.), check scripts/crowdin-glossary.json first — the term may already be defined. If not, consider adding it in the same PR.
See docs/i18n/crowdin-operations.md for operational details.